1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

107 Commits

Author SHA1 Message Date
semantic-release
e4cd4891ad 2.9.1
Automatically generated by python-semantic-release
2025-05-30 11:27:23 +00:00
12f8c82eb5 fix: make registry update log message debug level 2025-05-30 13:26:40 +02:00
semantic-release
f46ffb14e1 2.9.0
Automatically generated by python-semantic-release
2025-05-30 11:14:35 +00:00
2b9919bb34 docs: add usage docs for signal label widget 2025-05-30 13:13:55 +02:00
822e7d06ff feat: (#569) add signal label widget
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.
2025-05-30 13:13:55 +02:00
91195ae0fd fix(DeviceSignalInput): improve robustness
use set for storing filter properties to allow multiple set to true or
false
2025-05-30 13:13:55 +02:00
a6c5c21afa style: typing in bec_dispatcher 2025-05-30 13:13:55 +02:00
semantic-release
ff06954cb7 2.8.4
Automatically generated by python-semantic-release
2025-05-30 11:01:06 +00:00
c8128faf79 fix(crosshair): label decimal precision is dynamically scaled with the plot zoom; API of all affected widgets adjusted; option added to PlotBase; closes #637 2025-05-30 13:00:18 +02:00
semantic-release
6b65a94c81 2.8.3
Automatically generated by python-semantic-release
2025-05-30 09:03:15 +00:00
bf172b8431 fix: guard plugin repo import in e2e test 2025-05-30 11:02:14 +02:00
05329ab50f test(e2e): add tests involving plugin repo 2025-05-28 20:39:51 +02:00
b225a7cc90 refactor: store modules with widget search 2025-05-28 13:05:28 +02:00
semantic-release
3d8af05688 2.8.2
Automatically generated by python-semantic-release
2025-05-27 14:44:05 +00:00
0bdd4e86a2 fix(image_roi): rois are invertible by default, fixes resizing bug when adding from ROI manager 2025-05-27 16:43:22 +02:00
semantic-release
104e4e427b 2.8.1
Automatically generated by python-semantic-release
2025-05-27 14:34:15 +00:00
ada0977a1b fix(launch_window): font and tile size fixed across OSs, closes #607 2025-05-27 16:33:36 +02:00
semantic-release
1ea467c5fc 2.8.0
Automatically generated by python-semantic-release
2025-05-26 13:05:43 +00:00
4f69f5da45 refactor(toolbar): add warning if no parent is provided as it may lead to segfaults 2025-05-26 15:05:06 +02:00
d8547c7a56 fix(ImageProcessing): use target widget as parent 2025-05-26 15:05:06 +02:00
3484507c75 feat(plot_base): add option to specify units 2025-05-26 15:05:06 +02:00
8abebb7286 refactor(server): minor cleanup of imports 2025-05-26 15:05:06 +02:00
semantic-release
1d07e88b44 2.7.1
Automatically generated by python-semantic-release
2025-05-26 12:38:59 +00:00
1a4eb1db67 fix(signal-combobox): bug fix in signal combobox that crashed upon switching from device to signal input 2025-05-26 14:38:19 +02:00
f57950c4e3 test(input-widgets): add e2e tests to test widget inputs with demo config of bec. 2025-05-26 14:38:19 +02:00
a8811c9d91 refactor: add rpc interface to signal_line_edit/combobox; add user access methods 2025-05-26 14:38:19 +02:00
ec740d31fd fix(signal-line-edit): fix signal_line_edit validity check; closes #610 2025-05-26 14:38:19 +02:00
semantic-release
5c12ab1992 2.7.0
Automatically generated by python-semantic-release
2025-05-26 12:23:35 +00:00
ce88787e88 feat(image): roi plots with crosshair cuts added 2025-05-26 14:22:51 +02:00
e12e9e534d fix(image/image_selecetion): toolbar selection tool size adjusted 2025-05-26 14:22:51 +02:00
66e9445760 fix(plot_base/mouse_interactions.py): fixed parent 2025-05-26 14:22:51 +02:00
semantic-release
6bf4c53805 2.6.0
Automatically generated by python-semantic-release
2025-05-26 11:14:14 +00:00
a939c3b1c4 feat(image_roi_tree): gui roi manager for image widget 2025-05-26 13:13:31 +02:00
41b7ca8e64 fix(image_roi): position can be set from rpc 2025-05-26 13:13:31 +02:00
7a531c17d6 refactor(image_roi): glowing handles for Rectangle roi 2025-05-26 13:13:31 +02:00
a020f2dc7e feat(waveform): LMFitDialog cleanup after close 2025-05-26 13:13:31 +02:00
53377d26e2 ci: add pr issue sync 2025-05-23 17:27:54 +02:00
05489a1c56 chore: migrate issue template to github form syntax 2025-05-22 15:48:10 +02:00
semantic-release
0dfff71e4a 2.5.4
Automatically generated by python-semantic-release
2025-05-22 10:48:11 +00:00
d4def09a4e fix(dock_area): menu to add LogPanel into DockArea is temporary disabled 2025-05-22 12:47:21 +02:00
semantic-release
713653a4a5 2.5.3
Automatically generated by python-semantic-release
2025-05-22 09:59:18 +00:00
bcab66b187 fix(server): SimpleFileLikeFromLogOutputFunc added encoding for stdout 2025-05-22 11:58:30 +02:00
a345253c6e ci: reusable actions for installing bec widgets 2025-05-22 09:10:47 +02:00
semantic-release
bdf33a5249 2.5.2
Automatically generated by python-semantic-release
2025-05-22 07:07:24 +00:00
f8276f0224 fix: update gitignore 2025-05-22 09:06:43 +02:00
8227c44c33 docs: fix build process for sphinx 2025-05-21 14:21:16 +02:00
semantic-release
83098d930c 2.5.1
Automatically generated by python-semantic-release
2025-05-21 11:14:04 +00:00
a7ae856c8f fix(ui loader): fix loader for widget plugins 2025-05-21 13:13:18 +02:00
Klaus Wakonig
06f43e4883 docs: add kwargs to example 2025-05-21 09:29:24 +02:00
Klaus Wakonig
5ec9697271 docs(developer): fix hello world example 2025-05-21 09:29:24 +02:00
semantic-release
41296b5471 2.5.0
Automatically generated by python-semantic-release
2025-05-20 14:37:27 +00:00
1d018e863c feat(image_rois): image rois with RPC can be added to Image widget 2025-05-20 16:36:48 +02:00
6ee0f5004d ci: try uv for test env setup 2025-05-20 15:05:06 +02:00
semantic-release
40b5081632 2.4.3
Automatically generated by python-semantic-release
2025-05-19 15:25:35 +00:00
f064baae68 fix: twine upload key 2025-05-19 17:24:55 +02:00
semantic-release
58f01fb3a2 2.4.2
Automatically generated by python-semantic-release
2025-05-19 15:04:48 +00:00
1e344eacb7 fix: push release using GH_token 2025-05-19 17:04:04 +02:00
semantic-release
34002fa51a 2.4.1
Automatically generated by python-semantic-release
2025-05-19 14:34:58 +00:00
a00d510a75 fix: skip actions on new tags 2025-05-19 16:34:19 +02:00
semantic-release
120faf9523 2.4.0
Automatically generated by python-semantic-release
2025-05-19 13:53:08 +00:00
d7bd61f69e ci: use custom semver action 2025-05-19 15:50:24 +02:00
94bcfff724 ci: add known hosts 2025-05-19 15:10:38 +02:00
a17e7a0d52 ci: add deploy ssh key to release job 2025-05-19 15:02:54 +02:00
7f67d28887 ci: use ssh key for push 2025-05-19 14:39:01 +02:00
52d8e4b332 ci: build with ssh key 2025-05-19 14:26:17 +02:00
dea2b44e6a ci: fix job permissions for release 2025-05-19 13:47:25 +02:00
dc70ea6dfb ci: fix missing build dependencies 2025-05-19 13:32:16 +02:00
133ddda3e3 ci: fix missing build dependencies 2025-05-19 13:16:29 +02:00
8eee92e5cf ci: add semantic-release job 2025-05-19 12:57:17 +02:00
Klaus Wakonig
85de24aa89 chore: update issue templates 2025-05-17 20:38:32 +02:00
56b6a0b8c2 feat: add web console 2025-05-17 13:34:21 +02:00
d579d894f0 feat(modular_toolbar): remove action/bundle by id 2025-05-17 09:55:00 +02:00
d915d2f507 fix: (#612) fix additional MD form
makes sure the form is validated on any changes of the additional
metadata table model so that they are propagated to the scan control
widget even when nothing is entered in the standard form
2025-05-16 14:37:07 +02:00
7d7a88669f fix: (#572) signal input base filter
use name attribute rather than value from Kind, to compare with kind_str
2025-05-16 10:50:27 +02:00
a42dcec6d4 fix(entry_validator): device signals retrieved from ._info instead of .describe(), close #570 2025-05-15 15:33:00 +02:00
8cf1f09926 ci: exclude test dir from coverage report 2025-05-15 11:35:45 +02:00
83b153a14a ci: include lines with >=3 characters in report 2025-05-15 09:52:37 +02:00
aed450ef2c fix(side_panel): side panel can be open without icon; toolbar can be hidden if not needed 2025-05-15 08:20:19 +02:00
e60d0cb5ca ci: add generate-cli test 2025-05-14 23:37:15 +02:00
01870f9cda test: coverage report settings 2025-05-14 17:43:39 +02:00
483886495d ci: tidy workflow names 2025-05-14 17:43:39 +02:00
42502f6eed ci: only run tests if formatter passes 2025-05-14 17:43:39 +02:00
59d87e1c2f ci: no cov report with failed tests 2025-05-14 17:43:39 +02:00
Klaus Wakonig
3a5fa3d01a chore: update license 2025-05-14 16:36:03 +02:00
dbb3a1c1fb fix(workflows): update ophyd_devices clone URL to use GitHub 2025-05-14 16:15:06 +02:00
ca8211572f ci(workflows): update git clone URL for BEC repository to use GitHub 2025-05-14 15:41:12 +02:00
7584af4e44 ci: don't duplicate push & PR 2025-05-14 15:08:47 +02:00
95ef26565b ci: add codecov upload
and remove other coverage solution
2025-05-14 15:08:47 +02:00
abbf7a7f44 fix(device_input): remove unnecessary lowercase conversion for device selection 2025-05-14 11:58:41 +02:00
a301d37c4f ci: coverage 2025-05-13 19:29:11 +02:00
88a17a566c fix(layout_manager): adding relative widget is shifting whole column to not destroy previous layout 2025-05-13 11:33:16 +02:00
bf3746da0e refactor(color_button_native): color button with OS native dialog separated from the curve tree 2025-05-12 18:24:46 +02:00
e3205d6c97 ci: fix upload to codecov 2025-05-12 18:23:23 +02:00
Klaus Wakonig
507ac10e8d ci: add links to badges
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-12 18:23:23 +02:00
16e167019f ci: add coverage report 2025-05-12 18:23:23 +02:00
d712944e6b docs: badges extravaganza 2025-05-12 18:23:23 +02:00
d9b60c6cc9 docs: fix license reference 2025-05-12 15:37:10 +02:00
aee83e1a9e docs: add badge for code style, version and license 2025-05-12 15:37:10 +02:00
f5317341bf ci: add ci status badge 2025-05-12 15:37:10 +02:00
8345dacb26 ci: add github workflows 2025-05-12 13:44:37 +02:00
semantic-release
531d9c621d 2.3.0
Automatically generated by python-semantic-release
2025-05-09 12:36:13 +00:00
dc151cdfe3 feat(bec_connector): ability to change object name during runtime 2025-05-09 14:27:44 +02:00
semantic-release
e0dfd56a0d 2.2.0
Automatically generated by python-semantic-release
2025-05-09 09:41:37 +00:00
1fb680abb4 feat(launcher): add support for launching plugin widget 2025-05-08 17:30:16 +02:00
b9e56c96cb refactor(launch_window): widget tile added 2025-05-08 13:50:01 +02:00
semantic-release
dd956f18fe 2.1.3
Automatically generated by python-semantic-release
2025-05-07 14:31:53 +00:00
cf59d31113 fix(bec-dispatcher): fix reference to boundmethods to avoid duplicated subscriptions 2025-05-07 11:08:06 +02:00
112 changed files with 9197 additions and 2117 deletions

41
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View File

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

View File

@@ -1,3 +1,13 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEAT]: '
type: feature
label: feature
assignees: ''
---
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
@@ -37,4 +47,3 @@
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

64
.github/actions/bw_install/action.yml vendored Normal file
View 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]

View File

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

View 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()

View File

@@ -0,0 +1,2 @@
pydantic
pygithub

28
.github/workflows/check_pr.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Check PR status for branch
on:
workflow_call:
outputs:
branch-pr:
description: The PR number if the branch is in one
value: ${{ jobs.pr.outputs.branch-pr }}
jobs:
pr:
runs-on: "ubuntu-latest"
outputs:
branch-pr: ${{ steps.script.outputs.result }}
steps:
- uses: actions/github-script@v7
id: script
if: github.event_name == 'push' && github.event.ref_type != 'tag'
with:
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: context.repo.owner + ':${{ github.ref_name }}'
})
if (prs.data.length) {
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
return prs.data[0]["number"]
}

60
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Full CI
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
type: string
permissions:
pull-requests: write
jobs:
check_pr_status:
uses: ./.github/workflows/check_pr.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml

50
.github/workflows/end2end-conda.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

61
.github/workflows/formatter.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Formatter and Pylint jobs
on: [workflow_call]
jobs:
Formatter:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run black and isort
run: |
pip install black isort
pip install -e .[dev]
black --check --diff --color .
isort --check --diff ./
Pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint pylint-exit anybadge
- name: Run Pylint
run: |
mkdir -p ./pylint
set +e
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
pylint-exit $?
set -e
- name: Extract Pylint Score
id: score
run: |
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Create Badge
run: |
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: pylint-artifacts
path: |
# ./pylint/pylint.log # not sure why this isn't working
./pylint/pylint.svg

View File

@@ -0,0 +1,49 @@
name: Run bw-generate-cli
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install os dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run bw-generate-cli
run: |
bw-generate-cli --target bec_widgets
git diff --exit-code

59
.github/workflows/pytest-matrix.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Run Pytest with different Python versions
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
jobs:
pytest-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: ${{ matrix.python-version }}
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

64
.github/workflows/pytest.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
jobs:
pytest:
runs-on: ubuntu-latest
env:
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: 3.11
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets

103
.github/workflows/semantic_release.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Continuous Delivery
on:
push:
branches:
- main
# default: least privileged permissions across all jobs
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
cancel-in-progress: false
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
permissions:
contents: write
steps:
# Note: We checkout the repository at the branch that triggered the workflow
# with the entire history to ensure to match PSR's release branch detection
# and history evaluation.
# However, we forcefully reset the branch to the workflow sha because it is
# possible that the branch was updated while the workflow was running. This
# prevents accidentally releasing un-evaluated changes.
- name: Setup | Checkout Repository on Release Branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup | Force release branch to be at workflow sha
run: |
git reset --hard ${{ github.sha }}
- name: Evaluate | Verify upstream has NOT changed
# Last chance to abort before causing an error as another PR/push was applied to
# the upstream branch while this workflow was running. This is important
# because we are committing a version change (--commit). You may omit this step
# if you have 'commit: false' in your configuration.
#
# You may consider moving this to a repo script and call it from this step instead
# of writing it in-line.
shell: bash
run: |
set +o pipefail
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
set -o pipefail
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
exit 1
fi
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
exit 1
fi
HEAD_SHA="$(git rev-parse HEAD)"
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
exit 1
fi
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
- name: Semantic Version Release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install python-semantic-release==9.* wheel build twine
semantic-release -vv version
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
semantic-release publish

40
.github/workflows/sync-issues-pr.yml vendored Normal file
View 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
View File

@@ -64,6 +64,9 @@ coverage.xml
.pytest_cache/
cover/
# Output from end2end testing
tests/reference_failures/
# Translations
*.mo
*.pot

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2023, bec
Copyright (c) 2025, Paul Scherrer Institute
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -1,5 +1,16 @@
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -21,8 +21,10 @@ from qtpy.QtWidgets import (
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
@@ -35,11 +37,14 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
def __init__(
@@ -50,9 +55,15 @@ class LaunchTile(RoundedFrame):
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
):
super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
tile_size = self.DEFAULT_SIZE
self.tile_size = tile_size
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
@@ -83,12 +94,26 @@ class LaunchTile(RoundedFrame):
# Main label
self.main_label = QLabel(main_label)
# Desired default appearance
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
self.tile_size[0]
- self.layout.contentsMargins().left()
- self.layout.contentsMargins().right()
)
self._fit_label_to_width(self.main_label, content_width)
# Give every tile the same reserved height for the title so the
# description labels start at an identical yoffset.
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
@@ -129,6 +154,29 @@ class LaunchTile(RoundedFrame):
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
Fit the label text to the specified maximum width by adjusting the font size.
Args:
label(QLabel): The label to adjust.
max_width(int): The maximum width the label can occupy.
min_pt(int): The minimum font point size to use.
"""
font = label.font()
for pt in range(font.pointSize(), min_pt - 1, -1):
font.setPointSize(pt)
metrics = QFontMetrics(font)
if metrics.horizontalAdvance(label.text()) <= max_width:
label.setFont(font)
label.setWordWrap(False)
return
# If nothing fits, fall back to eliding
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
@@ -141,6 +189,9 @@ class LaunchWindow(BECMainWindow):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Track the smallest mainlabel font size chosen so far
self._min_main_label_pt: int | None = None
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
@@ -156,58 +207,125 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.tile_dock_area = LaunchTile(
self.register_tile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.tile_auto_update = LaunchTile(
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.tile_ui_file = LaunchTile(
self.register_tile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
# plugin widgets
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
tile_size=self.TILE_SIZE,
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
# keep all tiles' main labels at a unified point size
current_pt = tile.main_label.font().pointSize()
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
# New global minimum shrink every existing tile to this size
self._min_main_label_pt = current_pt
for t in self.tiles.values():
f = t.main_label.font()
f.setPointSize(self._min_main_label_pt)
t.main_label.setFont(f)
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
elif current_pt > self._min_main_label_pt:
# Tile is larger than global minimum shrink it to match
f = tile.main_label.font()
f.setPointSize(self._min_main_label_pt)
tile.main_label.setFont(f)
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
self.tiles[name] = tile
def launch(
self,
launch_script: str,
@@ -256,6 +374,12 @@ class LaunchWindow(BECMainWindow):
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
@@ -273,6 +397,7 @@ class LaunchWindow(BECMainWindow):
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
@@ -321,11 +446,28 @@ class LaunchWindow(BECMainWindow):
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles:
for tile in self.tiles.values():
tile.apply_theme(theme)
super().apply_theme(theme)
@@ -334,14 +476,25 @@ class LaunchWindow(BECMainWindow):
"""
Open the auto update window.
"""
if self.tile_auto_update.selector is None:
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tile_auto_update.selector.currentText()
auto_update = self.tiles["auto_update"].selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -51,16 +51,20 @@ _Widgets = {
"RingProgressBar": "RingProgressBar",
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget",
}
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
@@ -503,6 +507,224 @@ class BECStatusBox(RPCBase):
"""
class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self):
"""
Gets the coordinates that define this ROI's position and shape.
This is an abstract method that must be implemented by subclasses.
Implementations should return either a dictionary with descriptive keys
or a tuple of coordinates, depending on the value of self.description.
Returns:
dict or tuple: The coordinates defining the ROI's position and shape.
Raises:
NotImplementedError: This method must be implemented by subclasses.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
"""
Calculates and returns the coordinates and size of an object, either as a
typed dictionary or as a tuple.
Args:
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
to None, which utilizes the object's description value.
Returns:
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
if `typed` is True.
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class Curve(RPCBase):
@rpc_call
def remove(self):
@@ -720,9 +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.
"""
@@ -740,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.
"""
@@ -964,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":
@@ -1214,6 +1486,44 @@ class Image(RPCBase):
Access the main image item.
"""
@rpc_call
def add_roi(
self,
kind: "Literal['rect', 'circle']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
@rpc_call
def remove_roi(self, roi: "int | str"):
"""
Remove an ROI by index or label via the ROIController.
"""
@property
@rpc_call
def rois(self) -> "list[BaseROI]":
"""
Get the list of ROIs.
"""
class ImageItem(RPCBase):
@property
@@ -2055,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):
@@ -2317,6 +2641,115 @@ class PositionerGroup(RPCBase):
"""
class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
"""
Returns the coordinates of a rectangle's corners. Supports returning them
as either a dictionary with descriptive keys or a tuple of coordinates.
Args:
typed (bool | None): If True, returns coordinates as a dictionary with
descriptive keys. If False, returns them as a tuple. Defaults to
the value of `self.description`.
Returns:
dict | tuple: The rectangle's corner coordinates, where the format
depends on the `typed` parameter.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
@@ -2911,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":
@@ -2981,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."""
@@ -3311,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]":
@@ -3501,6 +4108,16 @@ class Waveform(RPCBase):
"""
class WebConsole(RPCBase):
"""A simple widget to display a website"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class WebsiteWidget(RPCBase):
"""A simple widget to display a website"""

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,9 +96,9 @@ class FakePositioner(BECPositioner):
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
}
}
self.signals = {
@@ -200,7 +200,13 @@ class DMMock:
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
def add_devices(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
for device in devices:
self.devices[device.name] = device

View File

@@ -205,6 +205,17 @@ class BECConnector:
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.

View File

@@ -4,8 +4,9 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -41,15 +42,25 @@ class QtThreadSafeCallback(QObject):
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return f"{id(self.cb)}{self.cb_info}".__hash__()
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -96,7 +107,7 @@ class BECDispatcher:
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
gui_id: str | None = None,
*args,
**kwargs,
):
@@ -109,7 +120,9 @@ class BECDispatcher:
if self._initialized:
return
self._slots = collections.defaultdict(set)
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
@@ -150,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:
@@ -159,35 +172,40 @@ 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.
"""
slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
self.client.connector.register(topics, cb=slot, **kwargs)
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
qt_slot.topics.update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
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,
# but the slot we receive here is the original callable
for connected_slot in self._slots:
for connected_slot in self._registered_slots.values():
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -198,11 +216,16 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
def disconnect_all(self, *args, **kwargs):
"""

View File

@@ -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())
...

View File

@@ -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 nonnegative
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
@@ -243,6 +314,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@@ -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,8 +395,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.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
@@ -330,7 +404,10 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
@@ -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)

View File

@@ -17,13 +17,23 @@ class EntryValidator:
raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name]
description = device.describe()
# Build list of available signal entries from device._info['signals']
signals_dict = getattr(device, "_info", {}).get("signals", {})
available_entries = [
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
]
# If no signals are found, means device is a signal, use the device name as the entry
if not available_entries:
available_entries = [name]
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
if entry not in available_entries:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
f"Entry '{entry}' not found in device '{name}' signals. "
f"Available signals: '{available_entries}'"
)
return entry

View File

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

View File

@@ -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)},

View File

@@ -31,6 +31,7 @@ class SidePanel(QWidget):
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
show_toolbar: bool = True,
):
super().__init__(parent=parent)
@@ -40,6 +41,7 @@ class SidePanel(QWidget):
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._show_toolbar = show_toolbar
self._panel_width = 0
self._panel_height = 0
@@ -71,13 +73,14 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._orientation in ("left", "right"):
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -102,11 +105,13 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
@@ -233,21 +238,24 @@ class SidePanel(QWidget):
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
action_id: str | None = None,
icon_name: str | None = None,
tooltip: str | None = None,
title: str | None = None,
):
) -> int:
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
title(str | None): The title of the panel.
Returns:
int: The index of the added panel, which can be used with show_panel() and switch_to().
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
@@ -278,32 +286,35 @@ class SidePanel(QWidget):
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
action.action.toggled.connect(on_action_toggled)
return index
############################################
@@ -332,41 +343,56 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.add_side_menus()
def add_side_menus(self):
# Example 1: With action, icon, and tooltip
widget1 = QWidget()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
widget=widget1,
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
# Example 2: With action, icon, and tooltip
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
widget=widget2,
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
# Example 3: With action, icon, and tooltip
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
widget=widget3,
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
widget4 = QWidget()
layout4 = QVBoxLayout(widget4)
layout4.addWidget(QLabel("This panel has no toolbar button"))
layout4.addWidget(QLabel("It can only be shown programmatically"))
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
# Example of how to show the hidden panel programmatically after 3 seconds
from qtpy.QtCore import QTimer
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
@@ -31,6 +32,8 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
@@ -173,6 +176,10 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
@@ -702,6 +709,85 @@ class ModularToolBar(QToolBar):
self.bundles[bundle_id].append(action_id)
self.update_separators()
def remove_action(self, action_id: str):
"""
Completely remove a single action from the toolbar.
The method takes care of both standalone actions and actions that are
part of an existing bundle.
Args:
action_id (str): Unique identifier for the action.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
# Identify potential bundle membership
parent_bundle = None
for b_id, a_ids in self.bundles.items():
if action_id in a_ids:
parent_bundle = b_id
break
# 1. Remove the QAction from the QToolBar and delete it
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# 2. Clean bundle bookkeeping if the action belonged to one
if parent_bundle:
self.bundles[parent_bundle].remove(action_id)
# If the bundle becomes empty, get rid of the bundle entry as well
if not self.bundles[parent_bundle]:
self.remove_bundle(parent_bundle)
# 3. Remove from the ordering list
self.toolbar_items = [
item
for item in self.toolbar_items
if not (item[0] == "action" and item[1] == action_id)
]
self.update_separators()
def remove_bundle(self, bundle_id: str):
"""
Remove an entire bundle (and all of its actions) from the toolbar.
Args:
bundle_id (str): Unique identifier for the bundle.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
# Remove every action belonging to this bundle
for action_id in list(self.bundles[bundle_id]): # copy the list
if action_id in self.widgets:
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# Drop the bundle entry
self.bundles.pop(bundle_id, None)
# Remove bundle entry and its preceding separator (if any) from the ordering list
cleaned_items = []
skip_next_separator = False
for item_type, ident in self.toolbar_items:
if item_type == "bundle" and ident == bundle_id:
# mark to skip one following separator if present
skip_next_separator = True
continue
if skip_next_separator and item_type == "separator":
skip_next_separator = False
continue
cleaned_items.append((item_type, ident))
self.toolbar_items = cleaned_items
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.

View File

@@ -2,6 +2,7 @@ from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
@@ -30,9 +31,9 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.custom_widgets = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
if PYSIDE6:
self.loader = self.load_ui_pyside6

View File

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

View File

@@ -53,7 +53,7 @@ class LayoutManagerWidget(QWidget):
self,
widget: QWidget | str,
row: int | None = None,
col: Optional[int] = None,
col: int | None = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
@@ -138,6 +138,39 @@ class LayoutManagerWidget(QWidget):
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position
# If adding to the left or right with shifting, shift the entire column
if (
position in ("left", "right")
and shift_existing
and shift_direction in ("left", "right")
):
column = ref_col
# Collect all rows in this column and sort for safe shifting
rows = sorted(
{row for (row, col) in self.position_widgets.keys() if col == column},
reverse=(shift_direction == "right"),
)
# Shift each widget in the column
for r in rows:
self.shift_widgets(direction=shift_direction, start_row=r, start_col=column)
# Update reference widget's position after the column shift
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
new_row = ref_row
# Compute insertion column based on relative position
if position == "left":
new_col = ref_col - ref_colspan
else:
new_col = ref_col + ref_colspan
# Add the new widget without triggering another shift
return self.add_widget(
widget=widget,
row=new_row,
col=new_col,
rowspan=rowspan,
colspan=colspan,
shift_existing=False,
)
if position == "left":
new_row = ref_row
new_col = ref_col - 1

View File

@@ -397,7 +397,7 @@ class DeviceInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."

View File

@@ -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
@@ -36,18 +37,20 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
def __init__(
self,
client=None,
config: DeviceSignalInputBaseConfig | dict | None = None,
gui_id: str = None,
**kwargs,
):
self.config = self._process_config_input(config)
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()
self._signal_filter = []
self._signal_filter = set()
self._signals = []
self._hinted_signals = []
self._normal_signals = []
@@ -58,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ###
@Slot(str)
@SafeSlot(str)
def set_signal(self, signal: str):
"""
Set the signal.
@@ -74,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.
@@ -88,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
):
@@ -102,10 +105,7 @@ class DeviceSignalInputBase(BECWidget):
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
@@ -113,30 +113,25 @@ class DeviceSignalInputBase(BECWidget):
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
FilterIO.set_selection(widget=self, selection=[self._device])
self._hinted_signals = [self._device]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
device_info = device._info.get("signals", {})
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
self._config_signals = _update(Kind.config)
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
@@ -164,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)
@@ -177,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)
@@ -190,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 ###
@@ -250,7 +245,7 @@ class DeviceSignalInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None
@@ -279,3 +274,8 @@ class DeviceSignalInputBase(BECWidget):
if signal in self.signals:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)

View File

@@ -22,10 +22,14 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt"
PLUGIN = True
@@ -140,7 +144,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.update()

View File

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

View File

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

View File

@@ -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_()

View File

@@ -53,6 +53,7 @@ class DictBackedTableModel(QAbstractTableModel):
if value in self._disallowed_keys or value in self._other_keys(index.row()):
return False
self._data[index.row()][index.column()] = str(value)
self.dataChanged.emit(index, index)
return True
return False
@@ -109,6 +110,7 @@ class DictBackedTableModel(QAbstractTableModel):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_updated = Signal()
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
@@ -141,6 +143,11 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""

View File

@@ -43,6 +43,7 @@ class ScanMetadata(PydanticModelForm):
self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_updated.connect(self.validate_form)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import secrets
import subprocess
import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class WebConsoleRegistry:
"""
A registry for the WebConsole class to manage its instances.
"""
def __init__(self):
"""
Initialize the registry.
"""
self._instances = {}
self._server_process = None
self._server_port = None
self._token = secrets.token_hex(16)
def register(self, instance: WebConsole):
"""
Register an instance of WebConsole.
"""
self._instances[instance.gui_id] = safe_ref(instance)
self.cleanup()
if self._server_process is None:
# Start the ttyd server if not already running
self.start_ttyd()
def start_ttyd(self, use_zsh: bool | None = None):
"""
Start the ttyd server
ttyd -q -W -t 'theme={"background": "black"}' zsh
Args:
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
"""
# First, check if ttyd is installed
try:
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
except FileNotFoundError:
# pylint: disable=raise-missing-from
raise RuntimeError("ttyd is not installed. Please install it first.")
if use_zsh is None:
# Check if we can use zsh
try:
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
use_zsh = True
except FileNotFoundError:
use_zsh = False
command = [
"ttyd",
"-p",
"0",
"-W",
"-t",
'theme={"background": "black"}',
"-c",
f"user:{self._token}",
]
if use_zsh:
command.append("zsh")
else:
command.append("bash")
# Start the ttyd server
self._server_process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._wait_for_server_port()
self._server_process.stdout.close()
self._server_process.stderr.close()
def _wait_for_server_port(self, timeout: float = 10):
"""
Wait for the ttyd server to start and get the port number.
Args:
timeout (float): The timeout in seconds to wait for the server to start.
"""
start_time = time.time()
while True:
output = self._server_process.stderr.readline()
if output == b"" and self._server_process.poll() is not None:
break
if not output:
continue
output = output.decode("utf-8").strip()
if "Listening on" in output:
# Extract the port number from the output
self._server_port = int(output.split(":")[-1])
logger.info(f"ttyd server started on port {self._server_port}")
break
if time.time() - start_time > timeout:
raise TimeoutError(
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
for gui_id, weak_ref in list(self._instances.items()):
if weak_ref() is None:
del self._instances[gui_id]
if not self._instances and self._server_process:
# If no instances are left, terminate the server process
self._server_process.terminate()
self._server_process = None
self._server_port = None
logger.info("ttyd server terminated")
def unregister(self, instance: WebConsole):
"""
Unregister an instance of WebConsole.
Args:
instance (WebConsole): The instance to unregister.
"""
if instance.gui_id in self._instances:
del self._instances[instance.gui_id]
self.cleanup()
_web_console_registry = WebConsoleRegistry()
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
class BECWebEnginePage(QWebEnginePage):
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
class WebConsole(BECWidget, QWidget):
"""
A simple widget to display a website
"""
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.browser = QWebEngineView(self)
self.page = BECWebEnginePage(self)
self.page.authenticationRequired.connect(self._authenticate)
self.browser.setPage(self.page)
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
def write(self, data: str, send_return: bool = True):
"""
Send data to the web page
"""
self.page.runJavaScript(f"window.term.paste('{data}');")
if send_return:
self.send_return()
def _authenticate(self, _, auth):
"""
Authenticate the request with the provided username and password.
"""
auth.setUser("user")
auth.setPassword(self._token)
def send_return(self):
"""
Send return to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
)
def send_ctrl_c(self):
"""
Send Ctrl+C to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
_web_console_registry.unregister(self)
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
widget = WebConsole()
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
DOM_XML = """
<ui language='c++'>
<widget class='WebConsole' name='web_console'>
</widget>
</ui>
"""
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = WebConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Console"
def icon(self):
return designer_material_icon(WebConsole.ICON_NAME)
def includeFile(self):
return "web_console"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "WebConsole"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -8,18 +8,27 @@ from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QPointF, Signal
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
RectangularROI,
ROIController,
)
logger = bec_logger.logger
@@ -79,6 +88,8 @@ 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",
@@ -111,8 +122,12 @@ class Image(PlotBase):
"transpose.setter",
"image",
"main_image",
"add_roi",
"remove_roi",
"rois",
]
sync_colorbar_with_autorange = Signal()
image_updated = Signal()
def __init__(
self,
@@ -128,6 +143,9 @@ class Image(PlotBase):
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
self.roi_controller = ROIController(colormap="viridis")
self.x_roi = None
self.y_roi = None
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
@@ -139,22 +157,60 @@ class Image(PlotBase):
# Default Color map to plasma
self.color_map = "plasma"
# Initialize ROI plots and side panels
self._add_roi_plots()
self.roi_manager_dialog = None
# Refresh theme for ROI plots
self._update_theme()
################################################################################
# Widget Specific GUI interactions
################################################################################
def apply_theme(self, theme: str):
super().apply_theme(theme)
if self.x_roi is not None and self.y_roi is not None:
self.x_roi.apply_theme(theme)
self.y_roi.apply_theme(theme)
def _init_toolbar(self):
# add to the first position
self.selection_bundle = MonitorSelectionToolbarBundle(
bundle_id="selection", target_widget=self
)
self.toolbar.add_bundle(self.selection_bundle, self)
self.toolbar.add_bundle(bundle=self.selection_bundle, target_widget=self)
super()._init_toolbar()
# Image specific changes to PlotBase toolbar
self.toolbar.widgets["reset_legend"].action.setVisible(False)
# ROI Bundle replacement with switchable crosshair
self.toolbar.remove_bundle("roi")
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
crosshair_roi = MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=self,
)
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
crosshair.action.toggled.connect(self.toggle_crosshair)
switch_crosshair = SwitchableToolBarAction(
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
initial_action="crosshair_simple",
tooltip="Crosshair",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
)
# Lock aspect ratio button
self.lock_aspect_ratio_action = MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
@@ -203,11 +259,8 @@ class Image(PlotBase):
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="autorange_image",
action=self.autorange_switch,
target_widget=self,
self.toolbar.add_action(
action_id="autorange_image", action=self.autorange_switch, target_widget=self
)
self.autorange_mean_action.action.toggled.connect(
@@ -239,11 +292,8 @@ class Image(PlotBase):
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="switch_colorbar",
action=self.colorbar_switch,
target_widget=self,
self.toolbar.add_action(
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
)
self.simple_colorbar_action.action.toggled.connect(
@@ -253,6 +303,55 @@ class Image(PlotBase):
lambda checked: self.enable_colorbar(checked, style="full")
)
########################################
# ROI Gui Manager
def add_side_menus(self):
super().add_side_menus()
roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
self.side_panel.add_menu(
action_id="roi_mgr",
icon_name="view_list",
tooltip="ROI Manager",
widget=roi_mgr,
title="ROI Manager",
)
def add_popups(self):
super().add_popups() # keep Axis Settings
roi_action = MaterialIconAction(
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
)
# self.popup_bundle.add_action("roi_mgr", roi_action)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
)
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
def show_roi_manager_popup(self):
roi_action = self.toolbar.widgets["roi_mgr"].action
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
self.roi_manager_dialog = QDialog(modal=False)
self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
self.roi_manager_dialog.show()
roi_action.setChecked(True)
else:
self.roi_manager_dialog.raise_()
self.roi_manager_dialog.activateWindow()
roi_action.setChecked(True)
def _roi_mgr_closed(self):
self.roi_mgr.close()
self.roi_mgr.deleteLater()
self.roi_manager_dialog.close()
self.roi_manager_dialog.deleteLater()
self.roi_manager_dialog = None
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
def enable_colorbar(
self,
enabled: bool,
@@ -304,9 +403,177 @@ class Image(PlotBase):
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
################################################################################
# Static rois with roi manager
def add_roi(
self,
kind: Literal["rect", "circle"] = "rect",
name: str | None = None,
line_width: int | None = 5,
pos: tuple[float, float] | None = (10, 10),
size: tuple[float, float] | None = (50, 50),
**pg_kwargs,
) -> RectangularROI | CircularROI:
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
if name is None:
name = f"ROI_{len(self.roi_controller.rois) + 1}"
if kind == "rect":
roi = RectangularROI(
pos=pos,
size=size,
parent_image=self,
line_width=line_width,
label=name,
**pg_kwargs,
)
elif kind == "circle":
roi = CircularROI(
pos=pos,
size=size,
parent_image=self,
line_width=line_width,
label=name,
**pg_kwargs,
)
else:
raise ValueError("kind must be 'rect' or 'circle'")
# Add to plot and controller (controller assigns color)
self.plot_item.addItem(roi)
self.roi_controller.add_roi(roi)
roi.add_scale_handle()
return roi
def remove_roi(self, roi: int | str):
"""Remove an ROI by index or label via the ROIController."""
if isinstance(roi, int):
self.roi_controller.remove_roi_by_index(roi)
elif isinstance(roi, str):
self.roi_controller.remove_roi_by_name(roi)
else:
raise ValueError("roi must be an int index or str name")
def _add_roi_plots(self):
"""
Initialize the ROI plots and side panels.
"""
# Create ROI plot widgets
self.x_roi = ImageROIPlot(parent=self)
self.y_roi = ImageROIPlot(parent=self)
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
# Set titles for the plots
self.x_roi.plot_item.setTitle("X ROI")
self.y_roi.plot_item.setTitle("Y ROI")
# Create side panels
self.side_panel_x = SidePanel(
parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
)
self.side_panel_y = SidePanel(
parent=self, orientation="left", panel_max_width=200, show_toolbar=False
)
# Add ROI plots to side panels
self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
# # Add side panels to the layout
self.layout_manager.add_widget_relative(
self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
)
self.layout_manager.add_widget_relative(
self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
)
def toggle_roi_panels(self, checked: bool):
"""
Show or hide the ROI panels based on the test action toggle state.
Args:
checked (bool): Whether the test action is checked.
"""
if checked:
# Show the ROI panels
self.hook_crosshair()
self.side_panel_x.show_panel(self.x_panel_index)
self.side_panel_y.show_panel(self.y_panel_index)
self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
self.image_updated.connect(self.update_image_slices)
else:
self.unhook_crosshair()
# Hide the ROI panels
self.side_panel_x.hide_panel()
self.side_panel_y.hide_panel()
self.image_updated.disconnect(self.update_image_slices)
@SafeSlot()
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
"""
Update the image slices based on the crosshair position.
Args:
coordinates(tuple): The coordinates of the crosshair.
"""
if coordinates is None:
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
if (
hasattr(self, "crosshair")
and hasattr(self.crosshair, "v_line")
and hasattr(self.crosshair, "h_line")
):
x = int(round(self.crosshair.v_line.value()))
y = int(round(self.crosshair.h_line.value()))
else:
return
else:
x = coordinates[1]
y = coordinates[2]
image = self._main_image.image
if image is None:
return
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
row, col = x, y
if not (0 <= row <= max_row and 0 <= col <= max_col):
return
# Horizontal slice
h_slice = image[:, col]
x_axis = np.arange(h_slice.shape[0])
self.x_roi.plot_item.clear()
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
# Vertical slice
v_slice = image[row, :]
y_axis = np.arange(v_slice.shape[0])
self.y_roi.plot_item.clear()
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
################################################################################
# Widget Specific Properties
################################################################################
################################################################################
# Rois
@property
def rois(self) -> list[BaseROI]:
"""
Get the list of ROIs.
"""
return self.roi_controller.rois
################################################################################
# Colorbar toggle
@@ -849,6 +1116,7 @@ class Image(PlotBase):
self._main_image.set_data(image_buffer)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
"""
@@ -900,6 +1168,7 @@ class Image(PlotBase):
self._main_image.set_data(data)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
################################################################################
# Clean up
@@ -925,6 +1194,11 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
# Remove all ROIs
rois = self.rois
for roi in rois:
roi.remove()
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
@@ -941,20 +1215,38 @@ class Image(PlotBase):
self._color_bar.deleteLater()
self._color_bar = None
# Popup cleanup
if self.roi_manager_dialog is not None:
self.roi_manager_dialog.reject()
self.roi_manager_dialog = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
# ROI plots cleanup
self.x_roi.cleanup_pyqtgraph()
self.y_roi.cleanup_pyqtgraph()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QHBoxLayout
app = QApplication(sys.argv)
widget = Image(popups=True)
widget.show()
widget.resize(1000, 800)
win = QWidget()
win.setWindowTitle("Image Demo")
ml = QHBoxLayout(win)
image_popup = Image(popups=True)
image_side_panel = Image(popups=False)
ml.addWidget(image_popup)
ml.addWidget(image_side_panel)
win.resize(1500, 800)
win.show()
sys.exit(app.exec_())

View 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()

View File

@@ -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_())

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,894 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pyqtgraph as pg
from pyqtgraph import TextItem
from pyqtgraph import functions as fn
from pyqtgraph import mkPen
from qtpy import QtCore
from qtpy.QtCore import QObject, Signal
from bec_widgets import SafeProperty
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class LabelAdorner:
"""Manages a TextItem label on top of any ROI, keeping it aligned."""
def __init__(
self,
roi: BaseROI,
anchor: tuple[int, int] = (0, 1),
padding: int = 2,
bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
text_color: str | tuple[int, int, int, int] = "white",
):
"""
Initializes a label overlay for a given region of interest (ROI), allowing for customization
of text placement, padding, background color, and text color. Automatically attaches the label
to the ROI and updates its position and content based on ROI changes.
Args:
roi: The region of interest to which the label will be attached.
anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
padding: Integer specifying the padding around the label's text. Default is 2.
bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
text_color: String specifying the color of the label's text. Default is "white".
"""
self.roi = roi
self.label = TextItem(anchor=anchor)
self.padding = padding
self.bg_rgba = bg_color
self.text_color = text_color
roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
# initial draw
self._update_html(roi.label)
self._reposition()
# reconnect on geometry/name changes
roi.sigRegionChanged.connect(self._reposition)
if hasattr(roi, "nameChanged"):
roi.nameChanged.connect(self._update_html)
def _update_html(self, text: str):
"""
Updates the HTML content of the label with the given text.
Creates an HTML div with the configured background color, text color, and padding,
then sets this HTML as the content of the label.
Args:
text (str): The text to display in the label.
"""
html = (
f'<div style="background: rgba{self.bg_rgba}; '
f"font-weight:bold; color:{self.text_color}; "
f'padding:{self.padding}px;">{text}</div>'
)
self.label.setHtml(html)
def _reposition(self):
"""
Repositions the label to align with the ROI's current position.
This method is called whenever the ROI's position or size changes.
It places the label at the bottom-left corner of the ROI's bounding rectangle.
"""
# put at top-left corner of ROIs bounding rect
size = self.roi.state["size"]
height = size[1]
self.label.setPos(0, height)
class BaseROI(BECConnector):
"""Base class for all Region of Interest (ROI) implementations.
This class serves as a mixin that provides common properties and methods for ROIs,
including name, line color, and line width properties. It inherits from BECConnector
to enable remote procedure call functionality.
Attributes:
RPC (bool): Flag indicating if remote procedure calls are enabled.
PLUGIN (bool): Flag indicating if this class is a plugin.
nameChanged (Signal): Signal emitted when the ROI name changes.
penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
USER_ACCESS (list): List of methods and properties accessible via RPC.
"""
RPC = True
PLUGIN = False
nameChanged = Signal(str)
penChanged = Signal()
USER_ACCESS = [
"label",
"label.setter",
"line_color",
"line_color.setter",
"line_width",
"line_width.setter",
"get_coordinates",
"get_data_from_image",
"set_position",
]
def __init__(
self,
*,
# BECConnector kwargs
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None,
# ROI-specific
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
"""Base class for all modular ROIs.
Args:
label (str): Human-readable name shown in ROI Manager and labels.
line_color (str | None, optional): Initial pen color. Defaults to None.
Controller may override color later.
line_width (int, optional): Initial pen width. Defaults to 15.
Controller may override width later.
config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
"""
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
self.config = config
self.set_parent(parent_image)
self.parent_plot_item = parent_image.plot_item
object_name = label.replace("-", "_").replace(" ", "_") if label else None
super().__init__(
object_name=object_name,
config=config,
gui_id=gui_id,
removable=True,
invertible=True,
**pg_kwargs,
)
self._label = label or "ROI"
self._line_color = line_color or "#ffffff"
self._line_width = line_width
self._description = True
self.setPen(mkPen(self._line_color, width=self._line_width))
def set_parent(self, parent: Image):
"""
Sets the parent image for this ROI.
Args:
parent (Image): The parent image object to associate with this ROI.
"""
self.parent_image = parent
def parent(self):
"""
Gets the parent image associated with this ROI.
Returns:
Image: The parent image object, or None if no parent is set.
"""
return self.parent_image
@property
def label(self) -> str:
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
return self._label
@label.setter
def label(self, new: str):
"""
Sets the display name of this ROI.
If the new name is different from the current name, this method updates
the internal name, emits the nameChanged signal, and updates the object name.
Args:
new (str): The new name to set for the ROI.
"""
if new != self._label:
self._label = new
self.nameChanged.emit(new)
self.change_object_name(new)
@property
def line_color(self) -> str:
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
return self._line_color
@line_color.setter
def line_color(self, value: str):
"""
Sets the line color of the ROI.
If the new color is different from the current color, this method updates
the internal color value, updates the pen while preserving the line width,
and emits the penChanged signal.
Args:
value (str): The new color to set for the ROI's outline (e.g., hex color code).
"""
if value != self._line_color:
self._line_color = value
# update pen but preserve width
self.setPen(mkPen(value, width=self._line_width))
self.penChanged.emit()
@property
def line_width(self) -> int:
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
return self._line_width
@line_width.setter
def line_width(self, value: int):
"""
Sets the line width of the ROI.
If the new width is different from the current width and is greater than 0,
this method updates the internal width value, updates the pen while preserving
the line color, and emits the penChanged signal.
Args:
value (int): The new width to set for the ROI's outline in pixels.
Must be greater than 0.
"""
if value != self._line_width and value > 0:
self._line_width = value
self.setPen(mkPen(self._line_color, width=value))
self.penChanged.emit()
@property
def description(self) -> bool:
"""
Gets whether ROI coordinates should be emitted with descriptive keys by default.
Returns:
bool: True if coordinates should include descriptive keys, False otherwise.
"""
return self._description
@description.setter
def description(self, value: bool):
"""
Sets whether ROI coordinates should be emitted with descriptive keys by default.
This affects the default behavior of the get_coordinates method.
Args:
value (bool): True to emit coordinates with descriptive keys, False to emit
as a simple tuple of values.
"""
self._description = value
def get_coordinates(self):
"""
Gets the coordinates that define this ROI's position and shape.
This is an abstract method that must be implemented by subclasses.
Implementations should return either a dictionary with descriptive keys
or a tuple of coordinates, depending on the value of self.description.
Returns:
dict or tuple: The coordinates defining the ROI's position and shape.
Raises:
NotImplementedError: This method must be implemented by subclasses.
"""
raise NotImplementedError("Subclasses must implement get_coordinates()")
def get_data_from_image(
self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
):
"""Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
if image_item is None:
image_item = next(
(
it
for it in self.scene().items()
if isinstance(it, pg.ImageItem) and it.image is not None
),
None,
)
if image_item is None:
raise RuntimeError("No ImageItem found in the current scene.")
data = image_item.image # the raw ndarray held by ImageItem
return self.getArrayRegion(
data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
)
def add_scale_handle(self):
return
def set_position(self, x: float, y: float):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
self.setPos(x, y)
def remove(self):
# Delegate to controller first so that GUI managers stay in sync
controller = getattr(self.parent_image, "roi_controller", None)
if controller and self in controller.rois:
controller.remove_roi(self)
return # controller will call back into this method once deregistered
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
viewBox = self.parent_plot_item.vb
viewBox.update()
class RectangularROI(BaseROI, pg.RectROI):
"""
Defines a rectangular Region of Interest (ROI) with additional functionality.
Provides tools for manipulating and extracting data from rectangular areas on
images, includes support for GUI features and event-driven signaling.
Attributes:
edgesChanged (Signal): Signal emitted when the ROI edges change, providing
the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
edgesReleased (Signal): Signal emitted when the ROI edges are released,
providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
"""
edgesChanged = Signal(float, float, float, float)
edgesReleased = Signal(float, float, float, float)
def __init__(
self,
*,
# pg.RectROI kwargs
pos: tuple[float, float],
size: tuple[float, float],
pen=None,
# BECConnector kwargs
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None = None,
# ROI specifics
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
resize_handles: bool = True,
**extra_pg,
):
"""
Initializes an instance with properties for defining a rectangular ROI with handles,
configurations, and an auto-aligning label. Also connects a signal for region updates.
Args:
pos: Initial position of the ROI.
size: Initial size of the ROI.
pen: Defines the border appearance; can be color or style.
config: Optional configuration details for the connection.
gui_id: Optional identifier for the associated GUI element.
parent_image: Optional parent object the ROI is related to.
label: Optional label for identification within the context.
line_color: Optional color of the ROI outline.
line_width: Width of the ROI's outline in pixels.
parent_plot_item: The plot item this ROI belongs to.
**extra_pg: Additional keyword arguments specific to pg.RectROI.
"""
super().__init__(
config=config,
gui_id=gui_id,
parent_image=parent_image,
label=label,
line_color=line_color,
line_width=line_width,
pos=pos,
size=size,
pen=pen,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self.adorner = LabelAdorner(roi=self)
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def add_scale_handle(self):
"""
Add scale handles at every corner and edge of the ROI.
Corner handles are anchored to the centre for two-axis scaling.
Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
"""
centre = [0.5, 0.5]
# Corner handles anchored to the centre for two-axis scaling
self.addScaleHandle([0, 0], centre) # topleft
self.addScaleHandle([1, 0], centre) # topright
self.addScaleHandle([0, 1], centre) # bottomleft
self.addScaleHandle([1, 1], centre) # bottomright
# Edge handles anchored to the midpoint of the opposite edge
self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
self.handleHoverBrush = (0, 255, 255, 160)
def _on_region_changed(self):
"""
Handles ROI region change events.
This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
viewBox = self.parent_plot_item.vb
viewBox.update()
def mouseDragEvent(self, ev):
"""
Handles mouse drag events on the ROI.
This method extends the parent class implementation to emit the edgesReleased
signal when the mouse drag is finished, providing the final coordinates of the ROI.
Args:
ev: The mouse event object containing information about the drag operation.
"""
super().mouseDragEvent(ev)
if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
Returns the coordinates of a rectangle's corners. Supports returning them
as either a dictionary with descriptive keys or a tuple of coordinates.
Args:
typed (bool | None): If True, returns coordinates as a dictionary with
descriptive keys. If False, returns them as a tuple. Defaults to
the value of `self.description`.
Returns:
dict | tuple: The rectangle's corner coordinates, where the format
depends on the `typed` parameter.
"""
if typed is None:
typed = self.description
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
if typed:
return {
"bottom_left": (x0, y0),
"bottom_right": (x1, y0),
"top_left": (x0, y1),
"top_right": (x1, y1),
}
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
def _lookup_scene_image(self):
"""
Searches for an image in the current scene.
This helper method iterates through all items in the scene and returns
the first pg.ImageItem that has a non-None image property.
Returns:
numpy.ndarray or None: The image from the first found ImageItem,
or None if no suitable image is found.
"""
for it in self.scene().items():
if isinstance(it, pg.ImageItem) and it.image is not None:
return it.image
return None
class CircularROI(BaseROI, pg.CircleROI):
"""Circular Region of Interest with center/diameter tracking and auto-labeling.
This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
that emits signals when its center or diameter changes, and includes an auto-aligning
label for visual identification.
Attributes:
centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
providing the new (center_x, center_y, diameter) values.
centerReleased (Signal): Signal emitted when the ROI is released after dragging,
providing the final (center_x, center_y, diameter) values.
"""
centerChanged = Signal(float, float, float)
centerReleased = Signal(float, float, float)
def __init__(
self,
*,
pos,
size,
pen=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None = None,
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
**extra_pg,
):
"""
Initializes a circular ROI with the specified properties.
Creates a circular ROI at the given position and with the given size,
connects signals for tracking changes, and attaches an auto-aligning label.
Args:
pos: Initial position of the ROI as [x, y].
size: Initial size of the ROI as [diameter, diameter].
pen: Defines the border appearance; can be color or style.
config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
label (str | None, optional): Display name for the ROI. Defaults to None.
line_color (str | None, optional): Color of the ROI outline. Defaults to None.
line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
parent_plot_item: The plot item this ROI belongs to.
**extra_pg: Additional keyword arguments for pg.CircleROI.
"""
super().__init__(
config=config,
gui_id=gui_id,
parent_image=parent_image,
label=label,
line_color=line_color,
line_width=line_width,
pos=pos,
size=size,
pen=pen,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self._adorner = LabelAdorner(self)
def _on_region_changed(self):
"""
Handles ROI region change events.
This method is called whenever the ROI's position or size changes.
It calculates the center coordinates and diameter of the circle and
emits the centerChanged signal with these values.
"""
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
self.centerChanged.emit(cx, cy, d)
viewBox = self.parent_plot_item.getViewBox()
viewBox.update()
def mouseDragEvent(self, ev):
"""
Handles mouse drag events on the ROI.
This method extends the parent class implementation to emit the centerReleased
signal when the mouse drag is finished, providing the final center coordinates
and diameter of the circular ROI.
Args:
ev: The mouse event object containing information about the drag operation.
"""
super().mouseDragEvent(ev)
if ev.isFinish():
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
self.centerReleased.emit(cx, cy, d)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
Calculates and returns the coordinates and size of an object, either as a
typed dictionary or as a tuple.
Args:
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
to None, which utilizes the object's description value.
Returns:
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
if `typed` is True.
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
"""
if typed is None:
typed = self.description
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
if typed:
return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
return (cx, cy, d, d / 2)
def _lookup_scene_image(self) -> pg.ImageItem | None:
"""
Retrieves an image from the scene items if available.
Iterates over all items in the scene and checks if any of them are of type
`pg.ImageItem` and have a non-None image. If such an item is found, its image
is returned.
Returns:
pg.ImageItem or None: The first found ImageItem with a non-None image,
or None if no suitable image is found.
"""
for it in self.scene().items():
if isinstance(it, pg.ImageItem) and it.image is not None:
return it.image
return None
class ROIController(QObject):
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
Handles creating, adding, removing, and managing ROI instances. Supports color assignment
from a colormap, and provides utility methods to access and manipulate ROIs.
Attributes:
roiAdded (Signal): Emits the new ROI instance when added.
roiRemoved (Signal): Emits the removed ROI instance when deleted.
cleared (Signal): Emits when all ROIs are removed.
paletteChanged (Signal): Emits the new colormap name when updated.
_colormap (str): Name of the colormap used for ROI colors.
_rois (list[BaseROI]): Internal list storing currently managed ROIs.
_colors (list[str]): Internal list of colors for the ROIs.
"""
roiAdded = Signal(object) # emits the new ROI instance
roiRemoved = Signal(object) # emits the removed ROI instance
cleared = Signal() # emits when all ROIs are removed
paletteChanged = Signal(str) # emits new colormap name
def __init__(self, colormap="viridis"):
"""
Initializes the ROI controller with the specified colormap.
Sets up internal data structures for managing ROIs and their colors.
Args:
colormap (str, optional): The name of the colormap to use for ROI colors.
Defaults to "viridis".
"""
super().__init__()
self._colormap = colormap
self._rois: list[BaseROI] = []
self._colors: list[str] = []
self._rebuild_color_buffer()
def _rebuild_color_buffer(self):
"""
Regenerates the color buffer for ROIs.
This internal method creates a new list of colors based on the current colormap
and the number of ROIs. It ensures there's always one more color than the number
of ROIs to allow for adding a new ROI without regenerating the colors.
"""
n = len(self._rois) + 1
self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
def add_roi(self, roi: BaseROI):
"""
Registers an externally created ROI with this controller.
Adds the ROI to the internal list, assigns it a color from the color buffer,
ensures it has an appropriate line width, and emits the roiAdded signal.
Args:
roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
such as RectangularROI or CircularROI.
"""
self._rois.append(roi)
self._rebuild_color_buffer()
idx = len(self._rois) - 1
if roi.label == "ROI" or roi.label.startswith("ROI "):
roi.label = f"ROI {idx}"
color = self._colors[idx]
roi.line_color = color
# ensure line width default is at least 3 if not previously set
if getattr(roi, "line_width", 0) < 1:
roi.line_width = 5
self.roiAdded.emit(roi)
def remove_roi(self, roi: BaseROI):
"""
Removes an ROI from this controller.
If the ROI is found in the internal list, it is removed, the color buffer
is regenerated, and the roiRemoved signal is emitted.
Args:
roi (BaseROI): The ROI instance to remove.
"""
if roi in self._rois:
self.roiRemoved.emit(roi)
self._rois.remove(roi)
roi.remove()
self._rebuild_color_buffer()
else:
roi.remove()
def get_roi(self, index: int) -> BaseROI | None:
"""
Returns the ROI at the specified index.
Args:
index (int): The index of the ROI to retrieve.
Returns:
BaseROI or None: The ROI at the specified index, or None if the index
is out of range.
"""
if 0 <= index < len(self._rois):
return self._rois[index]
return None
def get_roi_by_name(self, name: str) -> BaseROI | None:
"""
Returns the first ROI with the specified name.
Args:
name (str): The name to search for (case-sensitive).
Returns:
BaseROI or None: The first ROI with a matching name, or None if no
matching ROI is found.
"""
for r in self._rois:
if r.label == name:
return r
return None
def remove_roi_by_index(self, index: int):
"""
Removes the ROI at the specified index.
Args:
index (int): The index of the ROI to remove.
"""
roi = self.get_roi(index)
if roi is not None:
self.remove_roi(roi)
def remove_roi_by_name(self, name: str):
"""
Removes the first ROI with the specified name.
Args:
name (str): The name of the ROI to remove (case-sensitive).
"""
roi = self.get_roi_by_name(name)
if roi is not None:
self.remove_roi(roi)
def clear(self):
"""
Removes all ROIs from this controller.
Iterates through all ROIs and removes them one by one, then emits
the cleared signal to notify listeners that all ROIs have been removed.
"""
for roi in list(self._rois):
self.remove_roi(roi)
self.cleared.emit()
def renormalize_colors(self):
"""
Reassigns palette colors to all ROIs in order.
Regenerates the color buffer based on the current colormap and number of ROIs,
then assigns each ROI a color from the buffer in the order they were added.
This is useful after changing the colormap or when ROIs need to be visually
distinguished from each other.
"""
self._rebuild_color_buffer()
for idx, roi in enumerate(self._rois):
roi.line_color = self._colors[idx]
@SafeProperty(str)
def colormap(self):
"""
Gets the name of the colormap used for ROI colors.
Returns:
str: The name of the colormap.
"""
return self._colormap
@colormap.setter
def colormap(self, cmap: str):
"""
Sets the colormap used for ROI colors.
Updates the internal colormap name and reassigns colors to all ROIs
based on the new colormap.
Args:
cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
"""
self.set_colormap(cmap)
def set_colormap(self, cmap: str):
Colors.validate_color_map(cmap)
self._colormap = cmap
self.paletteChanged.emit(cmap)
self.renormalize_colors()
@property
def rois(self) -> list[BaseROI]:
"""
Gets a copy of the list of ROIs managed by this controller.
Returns a new list containing all the ROIs currently managed by this controller.
The list is a copy, so modifying it won't affect the controller's internal list.
Returns:
list[BaseROI]: A list of all ROIs currently managed by this controller.
"""
return list(self._rois)
def cleanup(self):
for roi in self._rois:
self.remove_roi(roi)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,9 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover
@@ -40,49 +43,6 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
def __init__(self, color="#000000", parent=None):
"""Initialize the color button.
Args:
color (str): The initial color in hex format (e.g., '#000000').
parent: Optional QWidget parent.
"""
super().__init__(parent)
self.set_color(color)
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
def color(self):
"""Return the current color in hex."""
return self._color
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)
class CurveRow(QTreeWidgetItem):
DELETE_BUTTON_COLOR = "#CC181E"
"""A unified row that can represent either a device or a DAP curve.
@@ -193,7 +153,7 @@ class CurveRow(QTreeWidgetItem):
def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButton(self.config.color)
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button)
@@ -284,6 +244,11 @@ class CurveRow(QTreeWidgetItem):
self.dap_combo.deleteLater()
self.dap_combo = None
if getattr(self, "color_button", None) is not None:
self.color_button.close()
self.color_button.deleteLater()
self.color_button = None
# Remove the item from the tree widget
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
@@ -337,8 +302,8 @@ class CurveRow(QTreeWidgetItem):
self.config.label = f"{parent_conf.label}-{new_dap}"
# Common style fields
self.config.color = self.color_button.color()
self.config.symbol_color = self.color_button.color()
self.config.color = self.color_button.color
self.config.symbol_color = self.color_button.color
self.config.pen_style = self.style_combo.currentText()
self.config.pen_width = self.width_spin.value()
self.config.symbol_size = self.symbol_spin.value()

View File

@@ -86,6 +86,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",
@@ -414,6 +416,8 @@ class Waveform(PlotBase):
"""
Slot for when the axis settings dialog is closed.
"""
self.dap_summary.close()
self.dap_summary.deleteLater()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
self.toolbar.widgets["fit_params"].action.setChecked(False)
@@ -1466,7 +1470,7 @@ class Waveform(PlotBase):
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
else: # history data
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
new_suffix = f" [custom: {x_name}-{x_entry}]"
new_suffix = f" (custom: {x_name}-{x_entry})"
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
@@ -1475,19 +1479,19 @@ class Waveform(PlotBase):
else: # history data
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
x_data = timestamps
new_suffix = " [timestamp]"
new_suffix = " (timestamp)"
# 3 User wants index
if self.x_axis_mode["name"] == "index":
x_data = None
new_suffix = " [index]"
new_suffix = " (index)"
# 4 Best effort automatic mode
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
# 4.1 If there are async curves, use index
if len(self._async_curves) > 0:
x_data = None
new_suffix = " [auto: index]"
new_suffix = " (auto: index)"
# 4.2 If there are sync curves, use the first device from the scan report
else:
try:
@@ -1501,7 +1505,7 @@ class Waveform(PlotBase):
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
new_suffix = f" [auto: {x_name}-{x_entry}]"
new_suffix = f" (auto: {x_name}-{x_entry})"
self._update_x_label_suffix(new_suffix)
return x_data

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.signal_label.signal_label_plugin import SignalLabelPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLabelPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View 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_())

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
DOM_XML = """
<ui language='c++'>
<widget class='SignalLabel' name='signal_label'>
</widget>
</ui>
"""
class SignalLabelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SignalLabel(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(SignalLabel.ICON_NAME)
def includeFile(self):
return "signal_label"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "SignalLabel"
def toolTip(self):
return "Display the live value of any signal"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,58 @@
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QPushButton
from bec_widgets import BECWidget, SafeProperty, SafeSlot
class ColorButtonNative(BECWidget, QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
RPC = False
PLUGIN = True
ICON_NAME = "colors"
def __init__(self, parent=None, color="#000000", **kwargs):
"""Initialize the color button.
Args:
parent: Optional QWidget parent.
color (str): The initial color in hex format (e.g., '#000000').
"""
super().__init__(parent=parent, **kwargs)
self.set_color(color)
@SafeSlot()
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
@SafeProperty("QColor")
def color(self):
"""Return the current color in hex."""
return self._color
@color.setter
def color(self, value):
"""Set the button's color and update its appearance."""
self.set_color(value)
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)

View File

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

View File

@@ -0,0 +1,56 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
DOM_XML = """
<ui language='c++'>
<widget class='ColorButtonNative' name='color_button_native'>
</widget>
</ui>
"""
class ColorButtonNativePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColorButtonNative(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColorButtonNative.ICON_NAME)
def includeFile(self):
return "color_button_native"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColorButtonNative"
def toolTip(self):
return "A QPushButton subclass that displays a color."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native_plugin import (
ColorButtonNativePlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonNativePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@@ -7,6 +7,4 @@ sphinx-copybutton
sphinx-inline-tabs
myst-parser
sphinx-design
PySide6~=6.8.2
bec-widgets
tomli

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View 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
```
````

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.1.2"
version = "2.9.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -38,6 +38,7 @@ dev = [
"pytest-timeout~=2.2",
"pytest-xvfb~=3.0",
"pytest~=8.0",
"pytest-cov~=6.1.1",
]
[project.urls]
@@ -70,7 +71,7 @@ include_trailing_comma = true
known_first_party = ["bec_widgets"]
[tool.semantic_release]
build_command = "python -m build"
build_command = "pip install build wheel && python -m build"
version_toml = ["pyproject.toml:project.version"]
[tool.semantic_release.commit_author]
@@ -96,12 +97,23 @@ default_bump_level = 0
[tool.semantic_release.remote]
name = "origin"
type = "gitlab"
ignore_token_for_push = false
type = "github"
ignore_token_for_push = true
[tool.semantic_release.remote.token]
env = "GL_TOKEN"
env = "GH_TOKEN"
[tool.semantic_release.publish]
dist_glob_patterns = ["dist/*"]
upload_to_vcs_release = true
[tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"return NotImplemented",
"raise NotImplementedError",
"\\.\\.\\.",
'if __name__ == "__main__":',
]

View File

@@ -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):
"""

View File

@@ -96,6 +96,10 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
if object_name == "VSCodeEditor":
continue
# Skip WebConsole as ttyd is not installed
if object_name == "WebConsole":
continue
#############################
######### Add widget ########
#############################

View File

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

View 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()

View File

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

View File

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

View File

@@ -82,3 +82,52 @@ def test_bec_connector_submit_task(bec_connector):
while not completed:
QApplication.processEvents()
time.sleep(0.1)
def test_bec_connector_change_object_name(bec_connector):
# Store the original object name and RPC register state
original_name = bec_connector.objectName()
original_gui_id = bec_connector.gui_id
# Call the method with a new name
new_name = "new_test_name"
bec_connector.change_object_name(new_name)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly
assert bec_connector.objectName() == new_name
assert bec_connector.object_name == new_name
# Verify that the object is registered in the RPC register with the new name
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the original name is no longer registered
# The object should still have the same gui_id
assert bec_connector.gui_id == original_gui_id
# Check that no object with the original name exists in the RPC register
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == original_name for obj in all_objects)
# Store the current name for the next test
previous_name = bec_connector.objectName()
# Test with spaces and hyphens
name_with_spaces_and_hyphens = "test name-with-hyphens"
expected_name = "test_name_with_hyphens"
bec_connector.change_object_name(name_with_spaces_and_hyphens)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly with replacements
assert bec_connector.objectName() == expected_name
assert bec_connector.object_name == expected_name
# Verify that the object is still registered in the RPC register after the second name change
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the previous name is no longer registered
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == previous_name for obj in all_objects)

View File

@@ -7,7 +7,7 @@ import pytest
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
@pytest.fixture
@@ -27,6 +27,7 @@ def bec_dispatcher_w_connector(bec_dispatcher, topics_msg_list, send_msg_event):
connector = QtRedisConnector("localhost:1", redis_class_mock)
bec_dispatcher.client.connector = connector
yield bec_dispatcher
connector.shutdown()
dummy_msg = MsgpackSerialization.dumps(ScanMessage(point_id=0, scan_id="0", data={}))
@@ -62,7 +63,6 @@ def test_dispatcher_disconnect_all(bec_dispatcher_w_connector, qtbot, send_msg_e
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_one(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
@@ -86,12 +86,21 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
num_slots = len(bec_dispatcher._registered_slots)
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb2, "topic1")
# The redis connector should only subscribe once to the topic
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(bec_dispatcher._slots) == 2
# The the given topic, two callbacks should be registered
assert len(bec_dispatcher.client.connector._topics_cb["topic1"]) == 2
# The dispatcher should have two slots
assert len(bec_dispatcher._registered_slots) == num_slots + 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher._slots) == 1
assert len(bec_dispatcher._registered_slots) == num_slots + 1
send_msg_event.set()
qtbot.wait(10)
@@ -99,9 +108,31 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb2.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_2_cb_same_topic_same_slot(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 1
)
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 1
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 0
)
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
@@ -114,3 +145,36 @@ def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_
send_msg_event.set()
qtbot.wait(10)
cb1.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb_with_boundmethod(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
bec_dispatcher = bec_dispatcher_w_connector
class MockObject:
def mock_slot(self, msg, metadata):
pass
cb1 = MockObject()
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
def _get_slots():
return list(
filter(
lambda slot: slot == QtThreadSafeCallback(cb1.mock_slot, {"metadata": "test"}),
bec_dispatcher._registered_slots.values(),
)
)
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(_get_slots()) == 1
bec_dispatcher.disconnect_slot(cb1.mock_slot, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 0
assert len(_get_slots()) == 0
send_msg_event.set()
qtbot.wait(10)

View File

@@ -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 == {}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
from .client_mocks import mocked_client
@@ -51,9 +52,13 @@ def test_device_input_base_init_with_config(mocked_client):
"default": "samx",
}
widget = DeviceInputWidget(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.device_filter == ["Positioner"]
assert widget.config.default == "samx"
widget2 = DeviceInputWidget(
client=mocked_client, config=DeviceInputConfig.model_validate(config)
)
for w in [widget, widget2]:
assert w.config.gui_id == "test_gui_id"
assert w.config.device_filter == ["Positioner"]
assert w.config.default == "samx"
def test_device_input_base_set_device_filter(device_input_base):

View File

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