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

Compare commits

...

68 Commits

Author SHA1 Message Date
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
65 changed files with 6205 additions and 1809 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"]
}

View File

@@ -1,15 +1,60 @@
name: Full CI
on: [push]
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

View File

@@ -35,9 +35,9 @@ jobs:
- 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://gitlab.psi.ch/bec/bec.git
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://gitlab.psi.ch/bec/ophyd_devices.git
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
cd ./bec
conda create -q -n test-environment python=3.11

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

View File

@@ -1,5 +1,26 @@
name: Run Pytest with different Python versions
on: [workflow_call]
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
jobs:
pytest-matrix:
@@ -9,7 +30,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
@@ -17,30 +38,20 @@ jobs:
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
python-version: ${{ matrix.python-version }}
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/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://gitlab.psi.ch/bec/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: 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: |

View File

@@ -1,47 +1,64 @@
name: Run Pytest with Coverage
on: [workflow_call]
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
jobs:
pytest:
runs-on: ubuntu-latest
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
python-version: '3.11'
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/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://gitlab.psi.ch/bec/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: 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
run: |
pip install coverage pytest pytest-random-order
coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
coverage report
coverage xml
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

@@ -55,6 +55,7 @@ _Widgets = {
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget",
}
@@ -503,6 +504,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):
@@ -1214,6 +1433,44 @@ class Image(RPCBase):
Access the main image item.
"""
@rpc_call
def add_roi(
self,
kind: "Literal['rect', 'circle']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
@rpc_call
def remove_roi(self, roi: "int | str"):
"""
Remove an ROI by index or label via the ROIController.
"""
@property
@rpc_call
def rois(self) -> "list[BaseROI]":
"""
Get the list of ROIs.
"""
class ImageItem(RPCBase):
@property
@@ -2317,6 +2574,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."""
@@ -3501,6 +3867,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

@@ -38,6 +38,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 = {

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

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

@@ -702,6 +702,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
@@ -34,6 +35,9 @@ class UILoader:
self.custom_widgets = {widget.__name__: widget for widget in widgets}
plugin_widgets = get_all_plugin_widgets()
self.custom_widgets.update(plugin_widgets)
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:

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

@@ -36,14 +36,16 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
def __init__(
self,
client=None,
config: DeviceSignalInputBaseConfig | dict | None = None,
gui_id: str = None,
**kwargs,
):
self.config = self._process_config_input(config)
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()
@@ -102,10 +104,7 @@ class DeviceSignalInputBase(BECWidget):
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
@@ -116,27 +115,19 @@ class DeviceSignalInputBase(BECWidget):
FilterIO.set_selection(widget=self, selection=[self._device])
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
self._config_signals = _update(Kind.config)
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
@@ -250,7 +241,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 +270,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

@@ -140,7 +140,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

@@ -147,7 +147,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

@@ -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,25 @@ 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.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
RectangularROI,
ROIController,
)
logger = bec_logger.logger
@@ -111,6 +118,9 @@ class Image(PlotBase):
"transpose.setter",
"image",
"main_image",
"add_roi",
"remove_roi",
"rois",
]
sync_colorbar_with_autorange = Signal()
@@ -128,6 +138,7 @@ class Image(PlotBase):
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
self.roi_controller = ROIController(colormap="viridis")
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
@@ -139,6 +150,8 @@ class Image(PlotBase):
# Default Color map to plasma
self.color_map = "plasma"
self.roi_manager_dialog = None
################################################################################
# Widget Specific GUI interactions
################################################################################
@@ -253,6 +266,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 +366,82 @@ 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")
################################################################################
# Widget Specific Properties
################################################################################
################################################################################
# Rois
@property
def rois(self) -> list[BaseROI]:
"""
Get the list of ROIs.
"""
return self.roi_controller.rois
################################################################################
# Colorbar toggle
@@ -925,6 +1060,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,6 +1081,11 @@ 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()
@@ -951,10 +1096,19 @@ class Image(PlotBase):
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,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

@@ -0,0 +1,889 @@
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, **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

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

@@ -414,6 +414,8 @@ class Waveform(PlotBase):
"""
Slot for when the axis settings dialog is closed.
"""
self.dap_summary.close()
self.dap_summary.deleteLater()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
self.toolbar.widgets["fit_params"].action.setChecked(False)

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.3.0"
version = "2.6.0"
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

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

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

@@ -0,0 +1,333 @@
from __future__ import annotations
import numpy as np
import pytest
from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture
def image_widget(qtbot, mocked_client):
"""Create an Image widget with some test data."""
widget = create_widget(qtbot, Image, client=mocked_client)
# Add a simple test image
data = np.zeros((100, 100), dtype=float)
data[20:40, 20:40] = 5 # A square region with value 5
widget.main_image.set_data(data)
yield widget
@pytest.fixture
def roi_tree(qtbot, image_widget):
"""Create an ROI property tree widget linked to the image widget."""
tree = create_widget(qtbot, ROIPropertyTree, image_widget=image_widget)
yield tree
def test_initialization(roi_tree, image_widget):
"""Test that the widget initializes correctly with the right components."""
# Check the widget has the right structure
assert roi_tree.image_widget == image_widget
assert roi_tree.plot == image_widget.plot_item
assert roi_tree.controller == image_widget.roi_controller
assert isinstance(roi_tree.roi_items, dict)
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
# Check toolbar actions
assert hasattr(roi_tree, "add_rect_action")
assert hasattr(roi_tree, "add_circle_action")
assert hasattr(roi_tree, "expand_toggle")
# Check tree view setup
assert roi_tree.tree.columnCount() == 3
assert roi_tree.tree.headerItem().text(roi_tree.COL_ACTION) == "Actions"
assert roi_tree.tree.headerItem().text(roi_tree.COL_ROI) == "ROI"
assert roi_tree.tree.headerItem().text(roi_tree.COL_PROPS) == "Properties"
def test_controller_connection(roi_tree, image_widget):
"""Test that controller signals/slots are properly connected."""
roi = image_widget.add_roi(kind="rect", name="test_roi")
# Verify that ROI was added to the tree
assert roi in roi_tree.roi_items
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 1
# Remove ROI via controller and check that it's removed from the tree
image_widget.remove_roi(0)
assert roi not in roi_tree.roi_items
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 0
def test_expand_collapse_tree(roi_tree, image_widget):
"""Test that triggering the expand action expands and collapses all ROI items in the tree."""
roi1 = image_widget.add_roi(kind="rect", name="rect1")
roi2 = image_widget.add_roi(kind="circle", name="circle1")
item1 = roi_tree.roi_items[roi1]
item2 = roi_tree.roi_items[roi2]
# Initially, items should be collapsed
assert not item1.isExpanded()
assert not item2.isExpanded()
# Trigger expand
roi_tree.expand_toggle.action.trigger()
assert item1.isExpanded()
assert item2.isExpanded()
# Trigger collapse
roi_tree.expand_toggle.action.trigger()
assert not item1.isExpanded()
assert not item2.isExpanded()
def test_roi_properties_display(roi_tree, image_widget):
"""Test that ROI properties are displayed correctly in the tree."""
# Add ROI with specific properties
roi = image_widget.add_roi(kind="rect", name="prop_test", line_width=15)
roi.line_color = "#FF0000" # bright red
# Find the tree item
item = roi_tree.roi_items[roi]
# Check property display
assert item.text(roi_tree.COL_ROI) == "prop_test"
# Find the type item (first child)
type_item = item.child(0)
assert type_item.text(roi_tree.COL_ROI) == "Type"
assert type_item.text(roi_tree.COL_PROPS) == "RectangularROI"
# Find the width item (second child)
width_item = item.child(1)
assert width_item.text(roi_tree.COL_ROI) == "Line width"
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
assert width_spin.value() == 15
def test_roi_name_edit(roi_tree, image_widget, qtbot):
"""Test editing the ROI name in the tree."""
roi = image_widget.add_roi(kind="rect", name="original_name")
item = roi_tree.roi_items[roi]
# Edit the name - simulate user editing the item
item.setFlags(item.flags() | Qt.ItemIsEditable)
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
qtbot.wait(200)
# Check the ROI name was updated
assert roi.label == "new_name"
assert item.text(roi_tree.COL_ROI) == "new_name"
def test_roi_width_edit(roi_tree, image_widget, qtbot):
"""Test editing ROI line width via spin box."""
roi = image_widget.add_roi(kind="rect", name="width_test", line_width=5)
item = roi_tree.roi_items[roi]
# Find the width spin box
width_item = item.child(1) # Second child item (index 1)
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
# Change the width
width_spin.setValue(25)
qtbot.wait(200)
# Check the ROI width was updated
assert roi.line_width == 25
def test_delete_roi_button(roi_tree, image_widget, qtbot):
"""Test that the delete button correctly removes the ROI."""
roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi]
# Get the delete button
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
# Click the delete button
del_btn.click()
qtbot.wait(200)
# Verify ROI was removed
assert roi not in roi_tree.roi_items
assert roi not in image_widget.roi_controller.rois
def test_roi_color_change_from_roi(roi_tree, image_widget):
"""Test that changing the ROI color updates the tree display."""
roi = image_widget.add_roi(kind="rect", name="color_test")
item = roi_tree.roi_items[roi]
# Change the ROI color directly
roi.line_color = "#00FF00" # bright green
# Check that the color button was updated
color_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_PROPS)
assert color_btn.color == "#00FF00"
def test_colormap_change(roi_tree, image_widget):
"""Test changing the colormap affects ROI colors."""
# Add multiple ROIs
roi1 = image_widget.add_roi(kind="rect", name="r1")
roi2 = image_widget.add_roi(kind="circle", name="c1")
# Store original colors
orig_colors = [roi1.line_color, roi2.line_color]
# Change colormap to "plasma" from the color map widget
roi_tree.cmap.colormap = "plasma"
# Colors should have changed
new_colors = [roi1.line_color, roi2.line_color]
assert new_colors != orig_colors
def test_coordinates_update(roi_tree, image_widget):
"""Test that coordinates update when ROI is moved."""
# Add a rectangular ROI
roi = image_widget.add_roi(kind="rect", name="moving_roi", pos=(10, 10), size=(20, 20))
item = roi_tree.roi_items[roi]
# Find coordinate items (type and width are 0 and 1, coordinates start at 2)
coordinate_items = [item.child(i) for i in range(2, item.childCount())]
# Store initial coordinates
initial_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
# Move the ROI
roi.setPos(50, 50)
# Check that coordinates were updated
new_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
assert new_coords != initial_coords
def test_draw_mode_toggle(roi_tree, qtbot):
"""Test toggling draw modes."""
# Initially no draw mode
assert roi_tree._roi_draw_mode is None
# Toggle rect mode on
roi_tree.add_rect_action.action.toggle()
assert roi_tree._roi_draw_mode == "rect"
assert roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
# Toggle circle mode on (should turn off rect mode)
roi_tree.add_circle_action.action.toggle()
qtbot.wait(200)
assert roi_tree._roi_draw_mode == "circle"
assert not roi_tree.add_rect_action.action.isChecked()
assert roi_tree.add_circle_action.action.isChecked()
# Toggle circle mode off
roi_tree.add_circle_action.action.toggle()
assert roi_tree._roi_draw_mode is None
assert not roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
def test_add_roi_from_toolbar(qtbot, mocked_client):
"""Test creating ROIs using the toolbar and mouse interactions."""
# Create Image widget with ROI tree
widget = create_widget(qtbot, Image, client=mocked_client)
data = np.zeros((100, 100), dtype=float)
widget.main_image.set_data(data)
qtbot.waitExposed(widget)
roi_tree = create_widget(qtbot, ROIPropertyTree, image_widget=widget)
# Get initial ROI count
initial_roi_count = len(widget.roi_controller.rois)
# Test rectangle ROI creation
# 1. Activate rectangle drawing mode
roi_tree.add_rect_action.action.setChecked(True)
assert roi_tree._roi_draw_mode == "rect"
# Get plot widget and view
plot_item = widget.plot_item
view = plot_item.vb.scene().views()[0]
qtbot.waitExposed(view)
# Define start and end points for the ROI (in view coordinates)
start_pos = QPointF(20, 20)
end_pos = QPointF(60, 60)
# Map view coordinates to scene coordinates
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
# Map scene coordinates to widget coordinates
start_pos_widget = view.mapFromScene(start_pos_scene)
end_pos_widget = view.mapFromScene(end_pos_scene)
# Using qtbot to simulate mouse actions
# First click to start drawing
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
# Then move to end position
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
# Finally release to complete the ROI
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
# Wait for signals to process
qtbot.wait(200)
# Check that a new ROI was created
assert len(widget.roi_controller.rois) == initial_roi_count + 1
# Get the newly created ROI
new_roi = widget.roi_controller.rois[-1]
# Verify it's a rectangular ROI
assert isinstance(new_roi, RectangularROI)
# Test circle ROI creation
# Reset ROI draw mode
roi_tree.add_rect_action.action.setChecked(False)
roi_tree.add_circle_action.action.setChecked(True)
assert roi_tree._roi_draw_mode == "circle"
# Define new positions for circle ROI
start_pos = QPointF(30, 30)
end_pos = QPointF(50, 50)
# Map view coordinates to scene coordinates
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
# Map scene coordinates to widget coordinates
start_pos_widget = view.mapFromScene(start_pos_scene)
end_pos_widget = view.mapFromScene(end_pos_scene)
# Using qtbot to simulate mouse actions
# First click to start drawing
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
# Then move to end position
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
# Finally release to complete the ROI
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
# Wait for signals to process
qtbot.wait(200)
# Check that a new ROI was created
assert len(widget.roi_controller.rois) == initial_roi_count + 2
# Get the newly created ROI
new_roi = widget.roi_controller.rois[-1]
# Verify it's a circle ROI
assert isinstance(new_roi, CircularROI)

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
from typing import Literal
import numpy as np
import pytest
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle"])
def bec_image_widget_with_roi(qtbot, request, mocked_client):
"""Return (widget, roi, shape_label) for each ROI class."""
roi_type: Literal["rect", "circle"] = request.param
# Build an Image widget with a trivial 100×100 zeros array
widget: Image = create_widget(qtbot, Image, client=mocked_client)
data = np.zeros((100, 100), dtype=float)
data[20:40, 20:40] = 5 # content assertion for roi to check
widget.main_image.set_data(data)
# Add a single ROI via the public API
roi = widget.add_roi(kind=roi_type)
yield widget, roi, roi_type
def test_default_properties(bec_image_widget_with_roi):
"""Label, width, type sanitycheck."""
_widget, roi, roi_type = bec_image_widget_with_roi
assert roi.label.startswith("ROI")
assert roi.line_width == 5
# concrete subclass type
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
def test_coordinate_structures(bec_image_widget_with_roi):
"""Typed vs untyped coordinate structures are consistent."""
_widget, roi, _ = bec_image_widget_with_roi
raw = roi.get_coordinates(typed=False)
typed = roi.get_coordinates(typed=True)
# untyped is always a tuple
assert isinstance(raw, tuple)
# typed is always a dict and has same number of scalars as raw flattens to
assert isinstance(typed, dict)
assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
np.ravel(raw)
)
def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
"""Pixels reported by get_data_from_image have nonzero size and match ROI extents."""
widget, roi, _ = bec_image_widget_with_roi
pixels = roi.get_data_from_image() # autodetect ImageItem
assert pixels.size > 0 # ROI covers at least one pixel
# For rectangular ROI: pixel bounding box equals coordinate bbox
if isinstance(roi, RectangularROI):
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
# ensure ints inside image shape
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
expected = widget.main_image.image[y0:y1, x0:x1]
assert pixels.shape == expected.shape
@pytest.mark.parametrize("index", [0])
def test_controller_remove_by_index(bec_image_widget_with_roi, index):
"""Image.remove_roi(index) removes the graphics item and updates controller."""
widget, roi, _ = bec_image_widget_with_roi
controller: ROIController = widget.roi_controller
assert controller.rois # nonempty before
widget.remove_roi(index)
# ROI list now empty and item no longer in scene
assert not controller.rois
assert roi not in widget.plot_item.items
def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
widget: Image = create_widget(qtbot, Image, client=mocked_client)
# add two of each ROI type
for _kind in ("rect", "circle"):
widget.add_roi(kind=_kind)
widget.add_roi(kind=_kind)
colors = [r.line_color for r in widget.roi_controller.rois]
assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
def test_roi_label_and_signals(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.nameChanged.connect(lambda name: changed.append(name))
roi.label = "new_label"
assert roi.label == "new_label"
assert changed and changed[0] == "new_label"
def test_roi_line_color_and_width(bec_image_widget_with_roi):
_widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.penChanged.connect(lambda: changed.append(True))
roi.line_color = "#123456"
assert roi.line_color == "#123456"
roi.line_width = 5
assert roi.line_width == 5
assert changed # penChanged should have been emitted
def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
r1 = widget.add_roi(kind="rect", name="r1")
r2 = widget.add_roi(kind="circle", name="c1")
assert r1 in controller.rois and r2 in controller.rois
widget.remove_roi("r1")
assert r1 not in controller.rois and r2 in controller.rois
widget.remove_roi("c1")
assert not controller.rois
def test_roi_controller_colormap_changes(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
old_colors = [r.line_color for r in controller.rois]
controller.colormap = "plasma"
new_colors = [r.line_color for r in controller.rois]
assert old_colors != new_colors
assert all(isinstance(c, str) for c in new_colors)
def test_roi_controller_clear(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
controller = widget.roi_controller
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
roi = widget.add_roi(kind="rect")
# Remove all images from scene
for item in list(widget.plot_item.items):
if hasattr(item, "image"):
widget.plot_item.removeItem(item)
import pytest
with pytest.raises(RuntimeError):
roi.get_data_from_image()
def test_roi_remove_cleans_up(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
roi.remove()
assert roi not in widget.roi_controller.rois
assert roi not in widget.plot_item.items
def test_roi_controller_get_roi_methods(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
r1 = widget.add_roi(kind="rect", name="findme")
r2 = widget.add_roi(kind="circle")
controller = widget.roi_controller
assert controller.get_roi_by_name("findme") == r1
assert controller.get_roi(1) == r2
assert controller.get_roi(99) is None
assert controller.get_roi_by_name("notfound") is None
def test_roi_set_position(bec_image_widget_with_roi):
"""Test that set_position updates the ROI position and coordinates."""
widget, roi, _ = bec_image_widget_with_roi
# Save original coordinates
orig_coords = roi.get_coordinates(typed=False)
# Move ROI by a known offset
roi.set_position(10, 15)
new_coords = roi.get_coordinates(typed=False)
# The new position should reflect the set_position call
assert new_coords != orig_coords
# The first coordinate should match the new position
if hasattr(roi, "pos"):
pos = roi.pos()
assert int(pos.x()) == 10
assert int(pos.y()) == 15

View File

@@ -329,3 +329,88 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
assert bec_image_view.main_image.log is False
assert bec_image_view.transpose is False
assert bec_image_view.main_image.transpose is False
def test_roi_add_remove_and_properties(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
# Add ROIs
rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
assert rect in view.roi_controller.rois
assert circ in view.roi_controller.rois
assert rect.label == "rect_roi"
assert circ.label == "circ_roi"
assert rect.line_width == 7
assert circ.line_width == 5
# Change properties
rect.label = "rect_roi2"
circ.line_color = "#ff0000"
assert rect.label == "rect_roi2"
assert circ.line_color == "#ff0000"
# Remove by name
view.remove_roi("rect_roi2")
assert rect not in view.roi_controller.rois
# Remove by index
view.remove_roi(0)
assert not view.roi_controller.rois
def test_roi_controller_palette_signal(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
controller = view.roi_controller
changed = []
controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
view.add_roi(kind="rect")
controller.colormap = "plasma"
assert changed and changed[0] == "plasma"
def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
r1 = view.add_roi(kind="rect", name="r1")
r2 = view.add_roi(kind="circle", name="c1")
controller = view.roi_controller
assert controller.get_roi_by_name("r1") == r1
assert controller.get_roi(1) == r2
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
roi = view.add_roi(kind="rect")
# Remove all images from scene
for item in list(view.plot_item.items):
if hasattr(item, "image"):
view.plot_item.removeItem(item)
with pytest.raises(RuntimeError):
roi.get_data_from_image()
##################################################
# Settings and popups
##################################################
def test_show_roi_manager_popup(qtbot, mocked_client):
"""
Verify that the ROI-manager dialog opens and closes correctly,
and that the matching toolbar icon stays in sync.
"""
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
# ROI-manager toggle is exposed via the toolbar.
assert "roi_mgr" in view.toolbar.widgets
roi_action = view.toolbar.widgets["roi_mgr"].action
assert roi_action.isChecked() is False, "Should start unchecked"
# Open the popup.
view.show_roi_manager_popup()
assert view.roi_manager_dialog is not None
assert view.roi_manager_dialog.isVisible()
assert roi_action.isChecked() is True, "Icon should toggle on"
# Close again.
view.roi_manager_dialog.close()
assert view.roi_manager_dialog is None
assert roi_action.isChecked() is False, "Icon should toggle off"

View File

@@ -1,4 +1,5 @@
from typing import Optional
from __future__ import annotations
from unittest.mock import patch
import pytest
@@ -8,7 +9,8 @@ from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutM
class MockWidgetHandler:
def create_widget(self, widget_type: str) -> Optional[QWidget]:
def create_widget(self, widget_type: str) -> QWidget | None:
if widget_type == "ButtonWidget":
return QPushButton()
elif widget_type == "LabelWidget":
@@ -225,13 +227,11 @@ def test_add_widget_overlap_with_span(layout_manager):
@pytest.mark.parametrize(
"position, btn3_coords",
[("left", (1, 0)), ("right", (1, 2)), ("top", (0, 1)), ("bottom", (2, 1))],
"position, expected_position",
[("left", "left"), ("right", "right"), ("top", "top"), ("bottom", "bottom")],
)
def test_add_widget_relative(layout_manager, position, btn3_coords):
def test_add_widget_relative(layout_manager, position, expected_position):
"""Test adding a widget relative to an existing widget using parameterized data."""
expected_row, expected_col = btn3_coords
btn1 = QPushButton("Button 1")
btn2 = QPushButton("Button 2")
btn3 = QPushButton("Button 3")
@@ -241,9 +241,28 @@ def test_add_widget_relative(layout_manager, position, btn3_coords):
layout_manager.add_widget_relative(btn3, reference_widget=btn2, position=position)
assert layout_manager.get_widget(0, 0) == btn1
assert layout_manager.get_widget(1, 1) == btn2
assert layout_manager.get_widget(expected_row, expected_col) == btn3
# Get the actual positions of the widgets
btn1_pos = layout_manager.widget_positions[btn1]
btn2_pos = layout_manager.widget_positions[btn2]
btn3_pos = layout_manager.widget_positions[btn3]
# Check that btn1 and btn2 are still in the layout
assert btn1 in layout_manager.widget_positions
assert btn2 in layout_manager.widget_positions
# Check that btn3 is positioned correctly relative to btn2
if expected_position == "left":
assert btn3_pos[1] < btn2_pos[1] # btn3's column < btn2's column
assert btn3_pos[0] == btn2_pos[0] # same row
elif expected_position == "right":
assert btn3_pos[1] > btn2_pos[1] # btn3's column > btn2's column
assert btn3_pos[0] == btn2_pos[0] # same row
elif expected_position == "top":
assert btn3_pos[0] < btn2_pos[0] # btn3's row < btn2's row
assert btn3_pos[1] == btn2_pos[1] # same column
elif expected_position == "bottom":
assert btn3_pos[0] > btn2_pos[0] # btn3's row > btn2's row
assert btn3_pos[1] == btn2_pos[1] # same column
def test_add_widget_relative_invalid_position(layout_manager):
@@ -366,3 +385,74 @@ def test_shift_all_widgets_up_at_top_row(layout_manager):
layout_manager.shift_all_widgets(direction="up")
assert "Shifting widgets out of grid boundaries." in str(exc_info.value)
@pytest.mark.parametrize(
"test_id, position, shift_direction, additional_assertions",
[
(
"from_left",
"left",
"right",
[
# Additional assertions for the left test case
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn1_new_pos[1]
> btn1_pos[1], # column shifted right
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn2_new_pos[1]
> btn2_pos[1], # column shifted right
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
< btn2_new_pos[1], # btn3 is to the left of btn2
],
),
(
"from_right",
"right",
"right",
[
# Additional assertions for the right test case
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
> btn2_new_pos[1] # btn3 is to the right of btn2
],
),
],
)
def test_column_shift_when_adding_widget(
layout_manager, test_id, position, shift_direction, additional_assertions
):
"""Test that adding a widget to a column of widgets shifts the entire column appropriately."""
# Create a column of widgets
btn1 = QPushButton("Button 1")
btn2 = QPushButton("Button 2")
# Add btn1 at position (0, 1)
layout_manager.add_widget(btn1, row=0, col=1)
# Add btn2 below btn1
layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="bottom")
# Get the positions after initial setup
btn1_pos = layout_manager.widget_positions[btn1]
btn2_pos = layout_manager.widget_positions[btn2]
# Verify btn2 is below btn1 (same column)
assert btn1_pos[0] < btn2_pos[0] # btn2's row > btn1's row
assert btn1_pos[1] == btn2_pos[1] # same column
# Add a new button relative to btn2 with the specified position and shift_direction
btn3 = QPushButton("Button 3")
layout_manager.add_widget_relative(
btn3, reference_widget=btn2, position=position, shift_direction=shift_direction
)
# Get the updated positions
btn1_new_pos = layout_manager.widget_positions[btn1]
btn2_new_pos = layout_manager.widget_positions[btn2]
btn3_pos = layout_manager.widget_positions[btn3]
# Common assertions for both test cases
assert btn1_new_pos[1] == btn2_new_pos[1] # btn1 and btn2 still in same column
assert btn3_pos[0] == btn2_new_pos[0] # btn3 is in the same row as btn2
# Run additional assertions specific to each test case
for assertion in additional_assertions:
assertion(btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos)

View File

@@ -518,3 +518,156 @@ def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_acti
# Verify that fake_showMenu() was called.
assert call_flag, "Long press did not trigger showMenu() as expected."
# Additional tests for action/bundle removal
def test_remove_standalone_action(toolbar_fixture, icon_action, dummy_widget):
"""
Ensure that a standalone action is fully removed and no longer accessible.
"""
toolbar = toolbar_fixture
# Add the action and check it is present
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
assert icon_action.action in toolbar.actions()
# Now remove it
toolbar.remove_action("icon_action")
# Action bookkeeping
assert "icon_action" not in toolbar.widgets
# QAction list
assert icon_action.action not in toolbar.actions()
# Attempting to hide / show it should raise
with pytest.raises(ValueError):
toolbar.hide_action("icon_action")
with pytest.raises(ValueError):
toolbar.show_action("icon_action")
def test_remove_action_from_bundle(
toolbar_fixture, dummy_widget, icon_action, material_icon_action
):
"""
Remove a single action that is part of a bundle and verify cleanup.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="test_bundle",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
# Initial assertions
assert "test_bundle" in toolbar.bundles
assert "icon_action" in toolbar.widgets
assert "material_action" in toolbar.widgets
# Remove one action from the bundle
toolbar.remove_action("icon_action")
# icon_action should be fully gone
assert "icon_action" not in toolbar.widgets
assert icon_action.action not in toolbar.actions()
# Bundle tracking should be updated
assert "icon_action" not in toolbar.bundles["test_bundle"]
# The other action must still exist
assert "material_action" in toolbar.widgets
assert material_icon_action.action in toolbar.actions()
def test_remove_last_action_from_bundle_removes_bundle(toolbar_fixture, dummy_widget, icon_action):
"""
Removing the final action from a bundle should delete the bundle entry itself.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(bundle_id="single_action_bundle", actions=[("only_action", icon_action)])
toolbar.add_bundle(bundle, dummy_widget)
# Sanity check
assert "single_action_bundle" in toolbar.bundles
assert "only_action" in toolbar.widgets
# Remove the sole action
toolbar.remove_action("only_action")
# Bundle should be gone
assert "single_action_bundle" not in toolbar.bundles
# QAction removed
assert icon_action.action not in toolbar.actions()
def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
"""
Ensure that removing a bundle deletes all its actions and separators.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="to_remove",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
# Confirm bundle presence
assert "to_remove" in toolbar.bundles
# Remove the whole bundle
toolbar.remove_bundle("to_remove")
# Bundle mapping gone
assert "to_remove" not in toolbar.bundles
# All actions gone
for aid, act in [("icon_action", icon_action), ("material_action", material_icon_action)]:
assert aid not in toolbar.widgets
assert act.action not in toolbar.actions()
def test_trigger_removed_action_raises(toolbar_fixture, icon_action, dummy_widget, qtbot):
"""
Add an action, connect a mock slot, then remove the action and verify that
attempting to trigger it afterwards raises RuntimeError (since the underlying
QAction has been deleted).
"""
toolbar = toolbar_fixture
# Add the action and connect a mock slot
toolbar.add_action("icon_action", icon_action, dummy_widget)
called = []
def mock_slot():
called.append(True)
icon_action.action.triggered.connect(mock_slot)
# Trigger once to confirm connection works
icon_action.action.trigger()
assert called == [True]
# Now remove the action
toolbar.remove_action("icon_action")
# Allow deleteLater event to process
qtbot.wait(50)
# The underlying C++ object should be deleted; triggering should raise
with pytest.raises(RuntimeError):
icon_action.action.trigger()
def test_remove_nonexistent_action(toolbar_fixture):
"""
Attempting to remove an action that does not exist should raise ValueError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.remove_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
def test_remove_nonexistent_bundle(toolbar_fixture):
"""
Attempting to remove a bundle that does not exist should raise ValueError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.remove_bundle("nonexistent_bundle")
assert "Bundle 'nonexistent_bundle' does not exist." in str(excinfo.value)

View File

@@ -1,9 +1,11 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import MagicMock
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from qtpy.QtCore import QModelIndex, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import StrMetadataField
from bec_widgets.utils.widget_io import WidgetIO
@@ -540,6 +542,29 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"}
TEST_TABLE_ENTRY = [["test key 1", "test value 1"], ["test key 2", "test value 2"]]
def test_scan_metadata_is_updated_even_without_default_form_changes(
scan_control: ScanControl, qtbot
):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
scan_control._metadata_form._additional_metadata._add_button.click()
qtbot.wait(100)
table_model = scan_control._metadata_form._additional_metadata._table_model
model_key = table_model.index(0, 0, QModelIndex())
table_model.setData(model_key, "test key 1", Qt.EditRole)
model_value = model_key.siblingAtColumn(1)
table_model.setData(model_value, "test value 1", Qt.EditRole)
assert scan_control._metadata_form._additional_metadata.dump_dict() == {
"test key 1": "test value 1"
}
assert scan_control._scan_metadata == {"sample_name": "", "test key 1": "test value 1"}
def test_scan_metadata_is_connected(scan_control):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
@@ -548,16 +573,28 @@ def test_scan_metadata_is_connected(scan_control):
assert isinstance(sample_name, StrMetadataField)
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = [
["test key 1", "test value 1"],
["test key 2", "test value 2"],
]
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
assert scan_control._scan_metadata == {
"sample_name": "Test Sample",
"test key 1": "test value 1",
"test key 2": "test value 2",
}
assert scan_control._scan_metadata == TEST_MD
def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
assert scan_control._scan_metadata == TEST_MD
scans = SimpleNamespace(grid_scan=MagicMock())
with (
patch.object(scan_control, "scans", scans),
patch.object(scan_control, "get_scan_parameters", lambda: ((), {})),
):
scan_control.run_scan()
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):

View File

@@ -0,0 +1,90 @@
from unittest import mock
import pytest
from qtpy.QtNetwork import QAuthenticator
from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry
from .client_mocks import mocked_client
@pytest.fixture
def console_widget(qtbot, mocked_client):
with mock.patch(
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
) as mock_subprocess:
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
_web_console_registry._server_port = 12345
# Create the WebConsole widget
widget = WebConsole(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_web_console_widget_initialization(console_widget):
assert (
console_widget.page.url().toString()
== f"http://localhost:{_web_console_registry._server_port}"
)
def test_web_console_write(console_widget):
# Test the write method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!")
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
def test_web_console_write_no_return(console_widget):
# Test the write method with send_return=False
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!", send_return=False)
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
assert mock_run_js.call_count == 1
def test_web_console_send_return(console_widget):
# Test the send_return method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_return()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
assert mock_run_js.call_count == 1
def test_web_console_send_ctrl_c(console_widget):
# Test the send_ctrl_c method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_ctrl_c()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
assert mock_run_js.call_count == 1
def test_web_console_authenticate(console_widget):
# Test the _authenticate method
token = _web_console_registry._token
mock_auth = mock.MagicMock(spec=QAuthenticator)
console_widget._authenticate(None, mock_auth)
mock_auth.setUser.assert_called_once_with("user")
mock_auth.setPassword.assert_called_once_with(token)
def test_web_console_registry_wait_for_server_port():
# Test the _wait_for_server_port method
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
_web_console_registry._wait_for_server_port()
assert _web_console_registry._server_port == 12345
def test_web_console_registry_wait_for_server_port_timeout():
# Test the _wait_for_server_port method with timeout
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
with pytest.raises(TimeoutError):
_web_console_registry._wait_for_server_port(timeout=0.1)