Compare commits

..

1 Commits

167 changed files with 4251 additions and 16444 deletions
-41
View File
@@ -1,41 +0,0 @@
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
-64
View File
@@ -1,64 +0,0 @@
name: "BEC Widgets Install"
description: "Install BEC Widgets and related os dependencies"
inputs:
BEC_WIDGETS_BRANCH: # id of input
required: false
default: "main"
description: "Branch of BEC Widgets to install"
BEC_CORE_BRANCH: # id of input
required: false
default: "main"
description: "Branch of BEC Core to install"
OPHYD_DEVICES_BRANCH: # id of input
required: false
default: "main"
description: "Branch of Ophyd Devices to install"
PYTHON_VERSION: # id of input
required: false
default: "3.11"
description: "Python version to use"
runs:
using: "composite"
steps:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.PYTHON_VERSION }}
- name: Checkout BEC Core
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
path: ./bec
- name: Checkout Ophyd Devices
uses: actions/checkout@v4
with:
repository: bec-project/ophyd_devices
ref: ${{ inputs.OPHYD_DEVICES_BRANCH }}
path: ./ophyd_devices
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
path: ./bec_widgets
- name: Install dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Install Python dependencies
shell: bash
run: |
pip install uv
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec_widgets[dev,pyside6]
@@ -1,342 +0,0 @@
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()
@@ -1,2 +0,0 @@
pydantic
pygithub
-28
View File
@@ -1,28 +0,0 @@
name: Check PR status for branch
on:
workflow_call:
outputs:
branch-pr:
description: The PR number if the branch is in one
value: ${{ jobs.pr.outputs.branch-pr }}
jobs:
pr:
runs-on: "ubuntu-latest"
outputs:
branch-pr: ${{ steps.script.outputs.result }}
steps:
- uses: actions/github-script@v7
id: script
if: github.event_name == 'push' && github.event.ref_type != 'tag'
with:
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: context.repo.owner + ':${{ github.ref_name }}'
})
if (prs.data.length) {
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
return prs.data[0]["number"]
}
-60
View File
@@ -1,60 +0,0 @@
name: Full CI
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
type: string
permissions:
pull-requests: write
jobs:
check_pr_status:
uses: ./.github/workflows/check_pr.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
-58
View File
@@ -1,58 +0,0 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- name: Upload logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
retention-days: 7
-66
View File
@@ -1,66 +0,0 @@
name: Formatter and Pylint jobs
on: [workflow_call]
jobs:
Formatter:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run black and isort
run: |
pip install uv
uv pip install --system black isort
uv pip install --system -e .[dev]
black --check --diff --color .
isort --check --diff ./
- name: Check for disallowed imports from PySide
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
Pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint pylint-exit anybadge
- name: Run Pylint
run: |
mkdir -p ./pylint
set +e
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
pylint-exit $?
set -e
- name: Extract Pylint Score
id: score
run: |
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Create Badge
run: |
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: pylint-artifacts
path: |
# ./pylint/pylint.log # not sure why this isn't working
./pylint/pylint.svg
-49
View File
@@ -1,49 +0,0 @@
name: Run bw-generate-cli
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install os dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run bw-generate-cli
run: |
bw-generate-cli --target bec_widgets
git diff --exit-code
-59
View File
@@ -1,59 +0,0 @@
name: Run Pytest with different Python versions
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
jobs:
pytest-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: ${{ matrix.python-version }}
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
-64
View File
@@ -1,64 +0,0 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
jobs:
pytest:
runs-on: ubuntu-latest
env:
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: 3.11
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets
-103
View File
@@ -1,103 +0,0 @@
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
-15
View File
@@ -1,15 +0,0 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7
-40
View File
@@ -1,40 +0,0 @@
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
View File
@@ -64,9 +64,6 @@ coverage.xml
.pytest_cache/ .pytest_cache/
cover/ cover/
# Output from end2end testing
tests/reference_failures/
# Translations # Translations
*.mo *.mo
*.pot *.pot
@@ -0,0 +1,17 @@
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]
@@ -1,13 +1,3 @@
---
name: Documentation update request
about: Suggest an update to the docs
title: '[DOCS]: '
type: documentation
label: documentation
assignees: ''
---
## Documentation Section ## Documentation Section
[Specify the section or page of the documentation that needs updating] [Specify the section or page of the documentation that needs updating]
@@ -1,13 +1,3 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEAT]: '
type: feature
label: feature
assignees: ''
---
## Feature Summary ## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting] [Provide a brief and clear summary of the new feature you are requesting]
@@ -47,3 +37,4 @@ assignees: ''
## Additional Information ## 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] [Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
@@ -1,24 +1,19 @@
## Description ## Description
[Provide a brief description of the changes introduced by this pull request.] [Provide a brief description of the changes introduced by this merge request.]
## Related Issues ## Related Issues
[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`.] [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`.]
## Type of Change ## Type of Change
- Change 1 - Change 1
- Change 2 - Change 2
## How to test
- Run unit tests
- Open [widget] in designer and play around with the properties
## Potential side effects ## Potential side effects
[Describe any potential side effects or risks of merging this PR.] [Describe any potential side effects or risks of merging this MR.]
## Screenshots / GIFs (if applicable) ## Screenshots / GIFs (if applicable)
+5 -7
View File
@@ -7,13 +7,13 @@ version: 2
# Set the version of Python and other tools you might need # Set the version of Python and other tools you might need
build: build:
os: ubuntu-22.04 os: ubuntu-20.04
tools: tools:
python: "3.11" python: "3.10"
# Build documentation in the docs/ directory with Sphinx # Build documentation in the docs/ directory with Sphinx
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF # If using Sphinx, optionally build your docs in additional formats such as PDF
# formats: # formats:
@@ -21,7 +21,5 @@ sphinx:
# Optionally declare the Python requirements required to build your docs # Optionally declare the Python requirements required to build your docs
python: python:
install: install:
- requirements: docs/requirements.txt - requirements: docs/requirements.txt
- method: pip
path: .[dev]
+1542 -2467
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
BSD 3-Clause License BSD 3-Clause License
Copyright (c) 2025, Paul Scherrer Institute Copyright (c) 2023, bec
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
-11
View File
@@ -1,16 +1,5 @@
# BEC Widgets # 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:** **⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨 🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
+34 -187
View File
@@ -2,11 +2,11 @@ from __future__ import annotations
import os import os
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal # type: ignore from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QComboBox, QComboBox,
@@ -21,10 +21,8 @@ from qtpy.QtWidgets import (
import bec_widgets import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar from bec_widgets.utils.toolbar import ModularToolBar
@@ -37,14 +35,11 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame): class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal() open_signal = Signal()
def __init__( def __init__(
@@ -55,15 +50,9 @@ class LaunchTile(RoundedFrame):
main_label: str | None = None, main_label: str | None = None,
description: str | None = None, description: str | None = None,
show_selector: bool = False, show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
): ):
super().__init__(parent=parent, orientation="vertical") super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
tile_size = self.DEFAULT_SIZE
self.tile_size = tile_size
self.icon_label = QLabel(parent=self) self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100) self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True) self.icon_label.setScaledContents(True)
@@ -94,26 +83,12 @@ class LaunchTile(RoundedFrame):
# Main label # Main label
self.main_label = QLabel(main_label) self.main_label = QLabel(main_label)
# Desired default appearance
font_main = self.main_label.font() font_main = self.main_label.font()
font_main.setPointSize(14) font_main.setPointSize(14)
font_main.setBold(True) font_main.setBold(True)
self.main_label.setFont(font_main) self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter) self.main_label.setAlignment(Qt.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
self.tile_size[0]
- self.layout.contentsMargins().left()
- self.layout.contentsMargins().right()
)
self._fit_label_to_width(self.main_label, content_width)
# Give every tile the same reserved height for the title so the
# description labels start at an identical yoffset.
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
self.layout.addWidget(self.main_label) self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed) self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
@@ -154,29 +129,6 @@ class LaunchTile(RoundedFrame):
) )
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter) self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
Fit the label text to the specified maximum width by adjusting the font size.
Args:
label(QLabel): The label to adjust.
max_width(int): The maximum width the label can occupy.
min_pt(int): The minimum font point size to use.
"""
font = label.font()
for pt in range(font.pointSize(), min_pt - 1, -1):
font.setPointSize(pt)
metrics = QFontMetrics(font)
if metrics.horizontalAdvance(label.text()) <= max_width:
label.setFont(font)
label.setWordWrap(False)
return
# If nothing fits, fall back to eliding
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
class LaunchWindow(BECMainWindow): class LaunchWindow(BECMainWindow):
RPC = True RPC = True
@@ -189,9 +141,6 @@ class LaunchWindow(BECMainWindow):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance() self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Track the smallest mainlabel font size chosen so far
self._min_main_label_pt: int | None = None
# Toolbar # Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
@@ -207,125 +156,58 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget) self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget) self.setCentralWidget(self.central_widget)
self.register_tile( self.tile_dock_area = LaunchTile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started", top_label="Get started",
main_label="BEC Dock Area", main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.", description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
) )
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.available_auto_updates: dict[str, type[AutoUpdates]] = ( self.tile_auto_update = LaunchTile(
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"), icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated", top_label="Get automated",
main_label="BEC Auto Update Dock Area", main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.", description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True, show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
) )
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.register_tile( self.tile_ui_file = LaunchTile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"), icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized", top_label="Get customized",
main_label="Launch Custom UI File", main_label="Launch Custom UI File",
description="GUI application with custom UI file.", description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
) )
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# plugin widgets # Add tiles to the main layout
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict() self.central_widget.layout.addWidget(self.tile_dock_area)
if self.available_widgets: self.central_widget.layout.addWidget(self.tile_auto_update)
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0] self.central_widget.layout.addWidget(self.tile_ui_file)
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme() self._update_theme()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
)
self.register = RPCRegister() self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights) self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast() self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
tile_size=self.TILE_SIZE,
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
# keep all tiles' main labels at a unified point size
current_pt = tile.main_label.font().pointSize()
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
# New global minimum shrink every existing tile to this size
self._min_main_label_pt = current_pt
for t in self.tiles.values():
f = t.main_label.font()
f.setPointSize(self._min_main_label_pt)
t.main_label.setFont(f)
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
elif current_pt > self._min_main_label_pt:
# Tile is larger than global minimum shrink it to match
f = tile.main_label.font()
f.setPointSize(self._min_main_label_pt)
tile.main_label.setFont(f)
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
self.tiles[name] = tile
def launch( def launch(
self, self,
launch_script: str, launch_script: str,
@@ -374,12 +256,6 @@ class LaunchWindow(BECMainWindow):
auto_update = kwargs.pop("auto_update", None) auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update) return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None) launch = getattr(bw_launch, launch_script, None)
if launch is None: if launch is None:
raise ValueError(f"Launch script {launch_script} not found.") raise ValueError(f"Launch script {launch_script} not found.")
@@ -397,7 +273,6 @@ class LaunchWindow(BECMainWindow):
else: else:
window = BECMainWindow() window = BECMainWindow()
window.setCentralWidget(result_widget) window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show() window.show()
return result_widget return result_widget
@@ -446,28 +321,11 @@ class LaunchWindow(BECMainWindow):
window.show() window.show()
return window return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str): def apply_theme(self, theme: str):
""" """
Change the theme of the application. Change the theme of the application.
""" """
for tile in self.tiles.values(): for tile in self.tiles:
tile.apply_theme(theme) tile.apply_theme(theme)
super().apply_theme(theme) super().apply_theme(theme)
@@ -476,25 +334,14 @@ class LaunchWindow(BECMainWindow):
""" """
Open the auto update window. Open the auto update window.
""" """
if self.tiles["auto_update"].selector is None: if self.tile_auto_update.selector is None:
auto_update = None auto_update = None
else: else:
auto_update = self.tiles["auto_update"].selector.currentText() auto_update = self.tile_auto_update.selector.currentText()
if auto_update == "Default": if auto_update == "Default":
auto_update = None auto_update = None
return self.launch("auto_update", auto_update=auto_update) return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True) @SafeSlot(popup_error=True)
def _open_custom_ui_file(self): def _open_custom_ui_file(self):
""" """
@@ -542,7 +389,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [ remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id connection for connection in connections.values() if connection.parent_id != self.gui_id
] ]
return len(remaining_connections) <= 4 return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict): def _turn_off_the_lights(self, connections: dict):
""" """
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

+12 -879
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -111,7 +111,7 @@ _Widgets = {
self.content += """ self.content += """
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict() _plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets) Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
+4 -3
View File
@@ -31,9 +31,10 @@ class RPCWidgetHandler:
Returns: Returns:
None None
""" """
self._widget_classes = ( clss = get_custom_classes("bec_widgets")
get_custom_classes("bec_widgets") + get_all_plugin_widgets() self._widget_classes = get_all_plugin_widgets() | {
).as_dict(IGNORE_WIDGETS) cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECWidget: def create_widget(self, widget_type, **kwargs) -> BECWidget:
""" """
+1 -4
View File
@@ -6,6 +6,7 @@ import os
import signal import signal
import sys import sys
from contextlib import redirect_stderr, redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from typing import cast
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig from bec_lib.service_config import ServiceConfig
@@ -37,10 +38,6 @@ class SimpleFileLikeFromLogOutputFunc:
self._log_func(lines) self._log_func(lines)
self._buffer = [remaining] self._buffer = [remaining]
@property
def encoding(self):
return "utf-8"
def close(self): def close(self):
return return
@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg, "pg": pg,
"wh": wh, "wh": wh,
"dock": self.dock, "dock": self.dock,
"im": self.im, # "im": self.im,
# "mi": self.mi, # "mi": self.mi,
# "mm": self.mm, # "mm": self.mm,
# "lm": self.lm, # "lm": self.lm,
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# tab_widget.addTab(fifth_tab, "Waveform Next Gen") # tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4) # tab_widget.setCurrentIndex(4)
# #
sixth_tab = QWidget() # sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab) # sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=True) # self.im = Image()
self.mi = self.im.main_image # self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im) # sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen") # tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1) # tab_widget.setCurrentIndex(5)
# #
# seventh_tab = QWidget() # seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab) # seventh_tab_layout = QVBoxLayout(seventh_tab)
+8 -59
View File
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceClass": "ophyd.Device", "deviceClass": "ophyd.Device",
"deviceConfig": {}, "deviceConfig": {},
"deviceTags": {"user device"}, "deviceTags": ["user device"],
"enabled": enabled, "enabled": enabled,
"readOnly": False, "readOnly": False,
"name": self.name, "name": self.name,
@@ -89,28 +89,16 @@ class FakePositioner(BECPositioner):
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner", "deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400}, "deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": {"user motors"}, "deviceTags": ["user motors"],
"enabled": enabled, "enabled": enabled,
"readOnly": False, "readOnly": False,
"name": self.name, "name": self.name,
} }
self._info = { self._info = {
"signals": { "signals": {
"readback": { "readback": {"kind_str": "5"}, # hinted
"kind_str": "hinted", "setpoint": {"kind_str": "1"}, # normal
"component_name": "readback", "velocity": {"kind_str": "2"}, # config
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
} }
} }
self.signals = { self.signals = {
@@ -196,8 +184,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner): class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox""" """just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True): def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled) super().__init__(name, limits, read_value)
class Device(FakeDevice): class Device(FakeDevice):
@@ -212,49 +200,10 @@ class DMMock:
self.devices = DeviceContainer() self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled] self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devices(self, devices: list): def add_devives(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
for device in devices: for device in devices:
self.devices[device.name] = device self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [ DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0), FakePositioner("samx", limits=[-10, 10], read_value=2.0),
-11
View File
@@ -205,17 +205,6 @@ class BECConnector:
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}" f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
) )
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None: def _update_object_name(self) -> None:
""" """
Enforce a unique object name among siblings and register the object for RPC. Enforce a unique object name among siblings and register the object for RPC.
+20 -43
View File
@@ -4,9 +4,8 @@ import collections
import random import random
import string import string
from collections.abc import Callable from collections.abc import Callable
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union from typing import TYPE_CHECKING, Union
import louie
import redis import redis
from bec_lib.client import BECClient from bec_lib.client import BECClient
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
@@ -42,25 +41,15 @@ class QtThreadSafeCallback(QObject):
self.cb_info = cb_info self.cb_info = cb_info
self.cb = cb self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb) self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self): def __hash__(self):
# make 2 differents QtThreadSafeCallback to look # make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the # identical when used as dictionary keys, if the
# callback is the same # callback is the same
return f"{id(self.cb_ref)}{self.cb_info}".__hash__() return f"{id(self.cb)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
def __call__(self, msg_content, metadata): def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata) self.cb_signal.emit(msg_content, metadata)
@@ -107,7 +96,7 @@ class BECDispatcher:
cls, cls,
client=None, client=None,
config: str | ServiceConfig | None = None, config: str | ServiceConfig | None = None,
gui_id: str | None = None, gui_id: str = None,
*args, *args,
**kwargs, **kwargs,
): ):
@@ -120,9 +109,7 @@ class BECDispatcher:
if self._initialized: if self._initialized:
return return
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = ( self._slots = collections.defaultdict(set)
collections.defaultdict()
)
self.client = client self.client = client
if self.client is None: if self.client is None:
@@ -163,7 +150,7 @@ class BECDispatcher:
def connect_slot( def connect_slot(
self, self,
slot: Callable, slot: Callable,
topics: EndpointInfo | str | list[EndpointInfo] | list[str], topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None, cb_info: dict | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
@@ -172,40 +159,35 @@ class BECDispatcher:
Args: Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message the corresponding pub/sub message
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None. cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
""" """
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info) slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots: self.client.connector.register(topics, cb=slot, **kwargs)
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics) topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str)) self._slots[slot].update(set(topics_str))
def disconnect_slot( def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
):
""" """
Disconnect a slot from a topic. Disconnect a slot from a topic.
Args: Args:
slot(Callable): The slot to disconnect slot(Callable): The slot to disconnect
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from. topics(Union[str, list]): The topic(s) to disconnect from
""" """
# find the right slot to disconnect from ; # find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects, # slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable # but the slot we receive here is the original callable
for connected_slot in self._registered_slots.values(): for connected_slot in self._slots:
if connected_slot.cb == slot: if connected_slot.cb == slot:
break break
else: else:
return return
self.client.connector.unregister(topics, cb=connected_slot) self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics) topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._registered_slots[connected_slot].topics.difference_update(set(topics_str)) self._slots[connected_slot].difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics: if not self._slots[connected_slot]:
del self._registered_slots[connected_slot] del self._slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]): def disconnect_topics(self, topics: Union[str, list]):
""" """
@@ -216,16 +198,11 @@ class BECDispatcher:
""" """
self.client.connector.unregister(topics) self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics) topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
remove_slots = [] slot_topics = self._slots[slot]
for connected_slot in self._registered_slots.values(): slot_topics.difference_update(set(topics_str))
connected_slot.topics.difference_update(set(topics_str)) if not slot_topics:
del self._slots[slot]
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
def disconnect_all(self, *args, **kwargs): def disconnect_all(self, *args, **kwargs):
""" """
+16 -29
View File
@@ -3,17 +3,12 @@ from __future__ import annotations
import importlib.metadata import importlib.metadata
import inspect import inspect
import pkgutil import pkgutil
import traceback
from importlib import util as importlib_util from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType from types import ModuleType
from typing import Generator from typing import Generator
from bec_lib.logger import bec_logger from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
logger = bec_logger.logger
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]: def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
@@ -35,12 +30,7 @@ def _loaded_submodules_from_specs(
assert isinstance( assert isinstance(
submodule.__loader__, SourceFileLoader submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!" ), "Module found from FileFinder should have SourceFileLoader!"
try: submodule.__loader__.exec_module(submodule)
submodule.__loader__.exec_module(submodule)
except Exception as e:
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
yield submodule yield submodule
@@ -51,29 +41,27 @@ def _submodule_by_name(module: ModuleType, name: str):
return None return None
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer: def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their info.""" """Find any BECWidget subclasses in the given module and return them with their names."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
classes = inspect.getmembers( return dict(
module, inspect.getmembers(
predicate=lambda item: inspect.isclass(item) module,
and issubclass(item, BECWidget) predicate=lambda item: inspect.isclass(item)
and item is not BECWidget, and issubclass(item, BECWidget)
) and item is not BECWidget,
return BECClassContainer( )
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
for k, v in classes
) )
def _all_widgets_from_all_submods(module) -> BECClassContainer: def _all_widgets_from_all_submods(module):
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict.""" """Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module) widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"): if not hasattr(module, "__path__"):
return widgets return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)): for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets += _all_widgets_from_all_submods(submod) widgets.update(_all_widgets_from_all_submods(submod))
return widgets return widgets
@@ -87,16 +75,15 @@ def get_plugin_client_module() -> ModuleType | None:
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> BECClassContainer: def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
"""If there is a plugin repository installed, load all widgets from it.""" """If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin(): if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin) return _all_widgets_from_all_submods(plugin)
else: else:
return BECClassContainer() return {}
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module() client = get_plugin_client_module()
print(get_all_plugin_widgets())
... ...
-13
View File
@@ -1,13 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import QLabel
class ClickableLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.clicked.emit()
return super().mouseReleaseEvent(ev)
+5 -8
View File
@@ -15,15 +15,12 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
def get_theme_palette(): def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name()) if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors | None: def get_accent_colors() -> AccentColors | None:
+23 -110
View File
@@ -34,21 +34,13 @@ class Crosshair(QObject):
coordinatesChanged2D = Signal(tuple) coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple) coordinatesClicked2D = Signal(tuple)
def __init__( def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
self,
plot_item: pg.PlotItem,
precision: int | None = None,
*,
min_precision: int = 2,
parent=None,
):
""" """
Crosshair for 1D and 2D plots. Crosshair for 1D and 2D plots.
Args: Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached. plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range. precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
parent (QObject, optional): Parent object for the QObject. Defaults to None. parent (QObject, optional): Parent object for the QObject. Defaults to None.
""" """
super().__init__(parent) super().__init__(parent)
@@ -56,9 +48,7 @@ class Crosshair(QObject):
self.is_log_x = None self.is_log_x = None
self.is_derivative = None self.is_derivative = None
self.plot_item = plot_item self.plot_item = plot_item
self._precision = precision self.precision = precision
self._min_precision = max(0, int(min_precision)) # ensure nonnegative
self.v_line = pg.InfiniteLine(angle=90, movable=False) self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False) self.h_line = pg.InfiniteLine(angle=0, movable=False)
@@ -95,64 +85,13 @@ class Crosshair(QObject):
self.items = [] self.items = []
self.marker_moved_1d = {} self.marker_moved_1d = {}
self.marker_clicked_1d = {} self.marker_clicked_1d = {}
self.marker_2d_row = None self.marker_2d = None
self.marker_2d_col = None
self.update_markers() self.update_markers()
self.check_log() self.check_log()
self.check_derivatives() self.check_derivatives()
self._connect_to_theme_change() self._connect_to_theme_change()
@property
def precision(self) -> int | None:
"""Fixed number of decimals; ``None`` enables dynamic mode."""
return self._precision
@precision.setter
def precision(self, value: int | None):
"""
Set the fixed number of decimals to display.
Args:
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
"""
self._precision = value
@property
def min_precision(self) -> int:
"""Lower bound on decimals when dynamic precision is used."""
return self._min_precision
@min_precision.setter
def min_precision(self, value: int):
"""
Set the lower bound on decimals when dynamic precision is used.
Args:
value(int): The minimum number of decimals to display. Must be non-negative.
"""
self._min_precision = max(0, int(value))
def _current_precision(self) -> int:
"""
Get the current precision based on the view range or fixed precision.
"""
if self._precision is not None:
return self._precision
# Dynamically choose precision from the smaller visible span
view_range = self.plot_item.vb.viewRange()
x_span = abs(view_range[0][1] - view_range[0][0])
y_span = abs(view_range[1][1] - view_range[1][0])
# Ignore zero spans that can appear during initialisation
spans = [s for s in (x_span, y_span) if s > 0]
span = min(spans) if spans else 1.0
exponent = np.floor(np.log10(span)) # order of magnitude
decimals = max(0, int(-exponent) + 1)
return max(self._min_precision, decimals)
def _connect_to_theme_change(self): def _connect_to_theme_change(self):
"""Connect to the theme change signal.""" """Connect to the theme change signal."""
qapp = QApplication.instance() qapp = QApplication.instance()
@@ -256,23 +195,13 @@ class Crosshair(QObject):
marker_clicked_list.append(marker_clicked) marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d_row is not None and self.marker_2d_col is not None: if self.marker_2d is not None:
continue continue
# Create horizontal ROI for row highlighting self.marker_2d = pg.ROI(
if item.image is None: [0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
continue
self.marker_2d_row = pg.ROI(
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
) )
self.marker_2d_row.skip_auto_range = True self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_row) self.plot_item.addItem(self.marker_2d)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
def snap_to_data( def snap_to_data(
self, x: float, y: float self, x: float, y: float
@@ -312,10 +241,8 @@ class Crosshair(QObject):
y_values[name] = closest_y y_values[name] = closest_y
x_values[name] = closest_x x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
name = item.objectName() or str(id(item)) name = item.config.monitor or str(id(item))
image_2d = item.image image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors # Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1)) y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1)) x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@@ -384,7 +311,6 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe... # not sure how we got here, but just to be safe...
return return
precision = self._current_precision()
for item in self.items: for item in self.items:
if isinstance(item, pg.PlotDataItem): if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item)) name = item.name() or str(id(item))
@@ -395,19 +321,16 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y) x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = ( coordinate_to_emit = (
name, name,
round(x_snapped_scaled, precision), round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, precision), round(y_snapped_scaled, self.precision),
) )
self.coordinatesChanged1D.emit(coordinate_to_emit) self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item)) name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
# Set position of horizontal ROI (row) self.marker_2d.setPos([x, y])
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y) coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit) self.coordinatesChanged2D.emit(coordinate_to_emit)
else: else:
@@ -441,7 +364,6 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe... # not sure how we got here, but just to be safe...
return return
precision = self._current_precision()
for item in self.items: for item in self.items:
if isinstance(item, pg.PlotDataItem): if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item)) name = item.name() or str(id(item))
@@ -453,19 +375,16 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y) x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = ( coordinate_to_emit = (
name, name,
round(x_snapped_scaled, precision), round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, precision), round(y_snapped_scaled, self.precision),
) )
self.coordinatesClicked1D.emit(coordinate_to_emit) self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item)) name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
# Set position of horizontal ROI (row) self.marker_2d.setPos([x, y])
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y) coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit) self.coordinatesClicked2D.emit(coordinate_to_emit)
else: else:
@@ -505,17 +424,14 @@ class Crosshair(QObject):
""" """
x, y = pos x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y) x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
precision = self._current_precision() text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
for item in self.items: for item in self.items:
if isinstance(item, pg.ImageItem): if isinstance(item, pg.ImageItem):
image = item.image image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1)) ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1)) iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy] intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}" text += f"\nIntensity: {intensity:.{self.precision}g}"
break break
# Update coordinate label # Update coordinate label
self.coord_label.setText(text) self.coord_label.setText(text)
@@ -534,12 +450,9 @@ class Crosshair(QObject):
self.clear_markers() self.clear_markers()
def cleanup(self): def cleanup(self):
if self.marker_2d_row is not None: if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d_row) self.plot_item.removeItem(self.marker_2d)
self.marker_2d_row = None self.marker_2d = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.plot_item.removeItem(self.v_line) self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line) self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label) self.plot_item.removeItem(self.coord_label)
+3 -13
View File
@@ -17,23 +17,13 @@ class EntryValidator:
raise ValueError(f"Device '{name}' not found in current BEC session") raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name] 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 == "": if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in available_entries: if entry not in description:
raise ValueError( raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. " f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
f"Available signals: '{available_entries}'"
) )
return entry return entry
+10 -66
View File
@@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
from bec_qthemes import material_icon from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -14,20 +12,15 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame): class ExpandableGroupFrame(QFrame):
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all" EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all" COLLAPSED_ICON_NAME: str = "expand_all"
def __init__( def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self._expanded = expanded self._expanded = expanded
@@ -36,33 +29,19 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout() self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout) self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._create_title_layout(title, icon) self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget(self) self._contents = QWidget(self)
self._layout.addWidget(self._contents) self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state) self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def set_layout(self, layout: QLayout) -> None: def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout) self._contents.setLayout(layout)
@@ -71,8 +50,7 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot() @SafeSlot()
def switch_expanded_state(self): def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore self.expanded = not self.expanded # type: ignore
self._update_expansion_icon() self._update_icon()
self.expansion_state_changed.emit()
@SafeProperty(bool) @SafeProperty(bool)
def expanded(self): # type: ignore def expanded(self): # type: ignore
@@ -83,9 +61,8 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded self._expanded = expanded
self._contents.setVisible(expanded) self._contents.setVisible(expanded)
self.updateGeometry() self.updateGeometry()
self.adjustSize()
def _update_expansion_icon(self): def _update_icon(self):
self._expansion_button.setIcon( self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False) material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded if self.expanded
@@ -93,36 +70,3 @@ class ExpandableGroupFrame(QFrame):
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
) )
) )
@SafeProperty(str)
def icon_name(self): # type: ignore
return self._title_icon_name
@icon_name.setter
def icon_name(self, icon_name: str):
self._title_icon_name = icon_name
self._set_title_icon(self._title_icon_name)
def _set_title_icon(self, icon_name: str):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
frame = ExpandableGroupFrame()
layout = QVBoxLayout()
frame.set_layout(layout)
layout.addWidget(QLabel("test1"))
layout.addWidget(QLabel("test2"))
layout.addWidget(QLabel("test3"))
frame.show()
app.exec()
+9 -125
View File
@@ -8,8 +8,6 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger logger = bec_logger.logger
@@ -17,13 +15,11 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers""" """Abstract base class for widget filter handlers"""
@abstractmethod @abstractmethod
def set_selection(self, widget, selection: list[str | tuple]) -> None: def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget """Set the filtered_selection for the widget
Args: Args:
widget: Widget instance selection (list): Filtered selection of items
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
""" """
@abstractmethod @abstractmethod
@@ -38,37 +34,17 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection bool: True if the input text is in the filtered selection
""" """
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler): class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget""" """Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None: def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model """Set the selection for the widget to the completer model
Args: Args:
widget (QLineEdit): The QLineEdit widget widget (QLineEdit): The QLineEdit widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs. selection (list): Filtered selection of items
""" """
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter): if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget) completer = QCompleter(widget)
widget.setCompleter(completer) widget.setCompleter(completer)
@@ -88,47 +64,19 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())] model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler): class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget""" """Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None: def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model """Set the selection for the widget to the completer model
Args: Args:
widget (QComboBox): The QComboBox widget widget (QComboBox): The QComboBox widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs. selection (list): Filtered selection of items
""" """
widget.clear() widget.clear()
if len(selection) == 0: widget.addItems(selection)
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
def check_input(self, widget: QComboBox, text: str) -> bool: def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection """Check if the input text is in the filtered selection
@@ -142,40 +90,6 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
""" """
return text in [widget.itemText(i) for i in range(widget.count())] return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO: class FilterIO:
"""Public interface to set filters for input widgets. """Public interface to set filters for input widgets.
@@ -185,14 +99,13 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler} _handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod @staticmethod
def set_selection(widget, selection: list[str | tuple], ignore_errors=True): def set_selection(widget, selection: list, ignore_errors=True):
""" """
Retrieve value from the widget instance. Retrieve value from the widget instance.
Args: Args:
widget: Widget instance. widget: Widget instance.
selection (list[str | tuple]): Filtered selection of items. selection(list): List of filtered selection items.
If tuple, it contains (text, data) pairs.
ignore_errors(bool, optional): Whether to ignore if no handler is found. ignore_errors(bool, optional): Whether to ignore if no handler is found.
""" """
handler_class = FilterIO._find_handler(widget) handler_class = FilterIO._find_handler(widget)
@@ -226,35 +139,6 @@ class FilterIO:
) )
return None return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod @staticmethod
def _find_handler(widget): def _find_handler(widget):
""" """
+37 -134
View File
@@ -1,107 +1,76 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
from types import NoneType from types import NoneType
from typing import NamedTuple
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
logger = bec_logger.logger logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget): class TypedForm(BECWidget, QWidget):
PLUGIN = True PLUGIN = True
ICON_NAME = "list_alt" ICON_NAME = "list_alt"
value_changed = Signal() value_changed = Signal()
RPC = True RPC = False
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__( def __init__(
self, self,
parent=None, parent=None,
items: list[tuple[str, type]] | None = None, items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None, form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None, client=None,
**kwargs, **kwargs,
): ):
"""Widget with a list of form items based on a list of types. """Widget with a list of form items based on a list of types.
Args: Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type. items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items. form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be only one of items or form_item_specs should be
supplied. supplied.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
""" """
if items is not None and form_item_specs is not None: if (items is not None and form_item_specs is not None) or (
logger.error( items is None and form_item_specs is None
"Must specify one and only one of items and form_item_specs! Ignoring `items`." ):
) raise ValueError("Must specify one and only one of items and form_item_specs")
items = None
if items is None and form_item_specs is None:
logger.error("Must specify one and only one of items and form_item_specs!")
items = []
super().__init__(parent=parent, client=client, **kwargs) super().__init__(parent=parent, client=client, **kwargs)
self._items = form_item_specs or [ self._items = (
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display) form_item_specs
for name, item_type in items # type: ignore if form_item_specs is not None
] else [
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout() self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout) self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self) self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid = QWidget(parent=self._form_grid_container) self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container) self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout()) self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate() self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self): def populate(self):
self._clear_grid() self._clear_grid()
for r, item in enumerate(self._items): for r, item in enumerate(self._items):
self._add_griditem(item, r) self._add_griditem(item, r)
gl: QGridLayout = self._form_grid.layout()
gl.setRowStretch(gl.rowCount(), 1)
def _add_griditem(self, item: FormItemSpec, row: int): def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout() grid = self._form_grid.layout()
@@ -109,22 +78,19 @@ class TypedForm(BECWidget, QWidget):
label.setProperty("_model_field_name", item.name) label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name) label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0) grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item) widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed) widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1) grid.addWidget(widget, row, 1)
def enumerate_form_widgets(self): def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
"""Return a generator over the rows of the form, with the row number, the label widget (to
which the field name is attached as a property "_model_field_name"), and the entry widget"""
grid: QGridLayout = self._form_grid.layout() # type: ignore grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount() - 1): # One extra row for stretch
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return { return {
row.label.property("_model_field_name"): row.widget.getValue() grid.itemAtPosition(i, 0)
for row in self.enumerate_form_widgets() .widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
} }
def _clear_grid(self): def _clear_grid(self):
@@ -137,13 +103,10 @@ class TypedForm(BECWidget, QWidget):
old_layout.deleteLater() old_layout.deleteLater()
self._form_grid.deleteLater() self._form_grid.deleteLater()
self._form_grid = QWidget() self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid) self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize() self._form_grid.adjustSize()
self._form_grid_container.adjustSize() self._form_grid_container.adjustSize()
self.adjustSize() self.adjustSize()
@@ -151,56 +114,23 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self): def _new_grid_layout(self):
new_grid = QGridLayout() new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0) new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm): class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict) metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType) metadata_cleared = Signal(NoneType)
def __init__( def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
""" """
A form generated from a pydantic model. A form generated from a pydantic model.
Args: Args:
data_model (type[BaseModel]): the model class for which to generate a form. metadata_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
""" """
self._pretty_display = pretty_display self._md_schema = metadata_model
self._md_schema = data_model super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
super().__init__(
parent=parent,
form_item_specs=self._form_item_specs(),
enabled=enabled,
client=client,
**kwargs,
)
self._validity = CompactPopupWidget() self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore self._validity.compact_view = True # type: ignore
@@ -213,40 +143,13 @@ class PydanticModelForm(TypedForm):
self._layout.addWidget(self._validity) self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form) self.value_changed.connect(self.validate_form)
self._connect_to_theme_change()
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]): def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema self._md_schema = schema
self.populate() self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self): def _form_item_specs(self):
return [ return [
FormItemSpec( FormItemSpec(name=name, info=info, item_type=info.annotation)
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
for name, info in self._md_schema.model_fields.items() for name, info in self._md_schema.model_fields.items()
] ]
+31 -357
View File
@@ -1,44 +1,31 @@
from __future__ import annotations from __future__ import annotations
import typing
from abc import abstractmethod from abc import abstractmethod
from decimal import Decimal from decimal import Decimal
from types import GenericAlias, UnionType from types import UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args from typing import Callable, Protocol
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined from qtpy.QtCore import Signal # type: ignore
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLayout, QLayout,
QLineEdit, QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QRadioButton, QRadioButton,
QSizePolicy,
QSpinBox, QSpinBox,
QToolButton, QToolButton,
QVBoxLayout,
QWidget, QWidget,
) )
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import ( from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required, clearable_required,
field_default, field_default,
@@ -47,7 +34,6 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen, field_minlen,
field_precision, field_precision,
) )
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger logger = bec_logger.logger
@@ -60,36 +46,9 @@ class FormItemSpec(BaseModel):
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
item_type: type | UnionType | GenericAlias
name: str name: str
info: FieldInfo = FieldInfo() info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict, set] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget): class ClearableBoolEntry(QWidget):
@@ -135,20 +94,10 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip) self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
class DynamicFormItem(QWidget): class DynamicFormItem(QWidget):
valueChanged = Signal() valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent) super().__init__(parent)
self._spec = spec self._spec = spec
self._layout = QHBoxLayout() self._layout = QHBoxLayout()
@@ -158,17 +107,11 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description self._desc = self._spec.info.description
self.setLayout(self._layout) self.setLayout(self._layout)
self._add_main_widget() self._add_main_widget()
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore if clearable_required(spec.info):
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self._add_clear_button()
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod @abstractmethod
def getValue(self) -> DynamicFormItemType: ... def getValue(self): ...
@abstractmethod @abstractmethod
def setValue(self, value): ... def setValue(self, value): ...
@@ -178,11 +121,6 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any """Add the main data entry widget to self._main_widget and appply any
constraints from the field info""" constraints from the field info"""
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
button.setVisible(False)
def _describe(self, pad=" "): def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "") return pad + (self._desc if self._desc else "")
@@ -200,7 +138,7 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit() self.valueChanged.emit()
class StrFormItem(DynamicFormItem): class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
@@ -225,11 +163,11 @@ class StrFormItem(DynamicFormItem):
def setValue(self, value: str): def setValue(self, value: str):
if value is None: if value is None:
return self._main_widget.setText("") self._main_widget.setText("")
self._main_widget.setText(str(value)) self._main_widget.setText(value)
class IntFormItem(DynamicFormItem): class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
@@ -258,18 +196,18 @@ class IntFormItem(DynamicFormItem):
self._main_widget.setValue(value) self._main_widget.setValue(value)
class FloatDecimalFormItem(DynamicFormItem): class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None: def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox() self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget) self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, float, precision) min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_) self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_) self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision: if precision:
self._main_widget.setDecimals(precision) self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}" minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -286,13 +224,13 @@ class FloatDecimalFormItem(DynamicFormItem):
return self._default return self._default
return self._main_widget.value() return self._main_widget.value()
def setValue(self, value: float | Decimal): def setValue(self, value: float):
if value is None: if value is None:
self._main_widget.clear() self._main_widget.clear()
self._main_widget.setValue(float(value)) self._main_widget.setValue(value)
class BoolFormItem(DynamicFormItem): class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed) self._main_widget.stateChanged.connect(self._value_changed)
@@ -313,300 +251,36 @@ class BoolFormItem(DynamicFormItem):
self._main_widget.setChecked(value) self._main_widget.setChecked(value)
class BoolToggleFormItem(BoolFormItem): def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: if annotation in [str, str | None]:
if spec.info.default is PydanticUndefined: return StrMetadataField
spec.info.default = False if annotation in [int, int | None]:
super().__init__(parent=parent, spec=spec) return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
def _add_main_widget(self) -> None: return FloatDecimalMetadataField
self._main_widget = ToggleSwitch() if annotation in [bool, bool | None]:
self._layout.addWidget(self._main_widget) return BoolMetadataField
self._main_widget.setToolTip(self._describe("")) else:
if self._default is not None: logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
self._main_widget.setChecked(self._default) return StrMetadataField
class DictFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
widget: type[QWidget]
default: int | float | str
class ListFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.annotation is list:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
elif isinstance(spec.info.annotation, GenericAlias):
args = set(typing.get_args(spec.info.annotation))
if args == {str}:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
if args == {int}:
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
if args == {float} or args == {int, float}:
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
else:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
super().__init__(parent=parent, spec=spec)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget: QListWidget
self._data = []
self._min_lines = 2 if spec.pretty_display else 4
self._repop(self._data)
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
self._layout.addWidget(self._main_widget)
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self._add_buttons()
def _add_buttons(self):
self._button_holder = QWidget()
self._buttons = QVBoxLayout()
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()
self._button_holder.setHidden(True)
def _repop(self, data):
self._main_widget.clear()
for val in data:
self._add_list_item(val)
self.scale_to_data()
def _add_data_item(self, val=None):
val = val or self._types.default
self._data.append(val)
self._add_list_item(val)
self._repop(self._data)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
WidgetIO.connect_widget_change_signal(item_widget, self._update)
return item_widget
def _update(self, _, value, *args):
self._data[self._main_widget.currentRow()] = value
@SafeSlot()
def _add_row(self):
self._add_data_item(self._types.default)
self._repop(self._data)
@SafeSlot()
def _delete_row(self):
if selected := self._main_widget.currentItem():
self._main_widget.removeItemWidget(selected)
row = self._main_widget.currentRow()
self._main_widget.takeItem(row)
self._data.pop(row)
self._repop(self._data)
@SafeSlot()
def clear(self):
self._repop([])
def getValue(self):
return self._data
def setValue(self, value: Iterable):
if set(map(type, value)) | {self._types.item} != {self._types.item}:
raise ValueError(f"This widget only accepts items of type {self._types.item}")
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
class SetFormItem(ListFormItem):
def _add_main_widget(self) -> None:
super()._add_main_widget()
self._add_item_field = self._types.widget()
self._buttons.addWidget(QLabel("Add new:"))
self._buttons.addWidget(self._add_item_field)
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
@SafeSlot()
def _add_row(self):
self._add_data_item(WidgetIO.get_value(self._add_item_field))
self._repop(self._data)
def _update(self, _, value, *args):
if value in self._data:
return
return super()._update(_, value, *args)
def _add_data_item(self, val=None):
val = val or self._types.default
if val == self._types.default or val in self._data:
return
self._data.append(val)
self._add_list_item(val)
def _add_list_item(self, val):
item_widget = super()._add_list_item(val)
if isinstance(item_widget, QLineEdit):
item_widget.setReadOnly(True)
return item_widget
def getValue(self):
return set(self._data)
def setValue(self, value: set):
return super().setValue(set(value))
class StrLiteralFormItem(DynamicFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(self._spec.info.annotation)
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
return
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
def clear(self):
self._main_widget.setCurrentIndex(-1)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
"float_decimal": (
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
FloatDecimalFormItem,
),
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
"dict": (
lambda spec: spec.item_type in [dict, dict | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
DictFormItem,
),
"list": (
lambda spec: spec.item_type in [list, list | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
ListFormItem,
),
"set": (
lambda spec: spec.item_type in [set, set | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
SetFormItem,
),
}
def widget_from_type(
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
) -> type[DynamicFormItem]:
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)
return StrFormItem
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel): class TestModel(BaseModel):
value0: set = Field(set(["a", "b"]))
value1: str | None = Field(None) value1: str | None = Field(None)
value2: bool | None = Field(None) value2: bool | None = Field(None)
value3: bool = Field(True) value3: bool = Field(True)
value4: int = Field(123) value4: int = Field(123)
value5: int | None = Field() value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
app = QApplication([]) app = QApplication([])
w = QWidget() w = QWidget()
layout = QGridLayout() layout = QGridLayout()
w.setLayout(layout) w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()): for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0) layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec) layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
items.append(widg)
layout.addWidget(widg, i, 1)
items[6].setValue([1, 2, 3, 4])
items[7].setValue(["1", "2", "asdfg", "qwerty"])
w.show() w.show()
app.exec() app.exec()
@@ -1,21 +0,0 @@
import bec_qthemes
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
QRadioButton {{ color: {foreground}; }}
QRadioButton::indicator::checked {{ color: {accent}; }}
QCheckBox {{ color: {accent}; }}
"""
if __name__ == "__main__":
print(pretty_display_theme())
+21 -10
View File
@@ -8,9 +8,6 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"] EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
class PluginFilenames(NamedTuple): class PluginFilenames(NamedTuple):
@@ -93,20 +90,34 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument # Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__) init_source = inspect.getsource(self.widget.__init__)
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE) cls_init_found = (
cls_init_found = class_re.search(init_source) is not None bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
super_self_re = re.compile( or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
) )
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found: if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the # for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class) # number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source) num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found: if len(num_inits) == 2 and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found: if not cls_init_found and not super_init_found:
raise ValueError( raise ValueError(
+26 -27
View File
@@ -4,7 +4,7 @@ import importlib
import inspect import inspect
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -90,15 +90,15 @@ class BECClassInfo:
name: str name: str
module: str module: str
file: str file: str
obj: type[BECWidget] obj: type
is_connector: bool = False is_connector: bool = False
is_widget: bool = False is_widget: bool = False
is_plugin: bool = False is_plugin: bool = False
class BECClassContainer: class BECClassContainer:
def __init__(self, initial: Iterable[BECClassInfo] = []): def __init__(self):
self._collection: list[BECClassInfo] = list(initial) self._collection: list[BECClassInfo] = []
def __repr__(self): def __repr__(self):
return str(list(cl.name for cl in self.collection)) return str(list(cl.name for cl in self.collection))
@@ -106,16 +106,6 @@ class BECClassContainer:
def __iter__(self): def __iter__(self):
return self._collection.__iter__() return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo): def add_class(self, class_info: BECClassInfo):
""" """
Add a class to the collection. Add a class to the collection.
@@ -125,44 +115,53 @@ class BECClassContainer:
""" """
self.collection.append(class_info) self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property @property
def collection(self): def collection(self):
"""Get the collection of classes.""" """
Get the collection of classes.
"""
return self._collection return self._collection
@property @property
def connector_classes(self): def connector_classes(self):
"""Get all connector classes.""" """
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector] return [info.obj for info in self.collection if info.is_connector]
@property @property
def top_level_classes(self): def top_level_classes(self):
"""Get all top-level classes.""" """
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_plugin] return [info.obj for info in self.collection if info.is_plugin]
@property @property
def plugins(self): def plugins(self):
"""Get all plugins. These are all classes that are on the top level and are widgets.""" """
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin] return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property @property
def widgets(self): def widgets(self):
"""Get all widgets. These are all classes inheriting from BECWidget.""" """
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget] return [info.obj for info in self.collection if info.is_widget]
@property @property
def rpc_top_level_classes(self): def rpc_top_level_classes(self):
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.""" """
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector] return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property @property
def classes(self): def classes(self):
"""Get all classes.""" """
Get all classes.
"""
return [info.obj for info in self.collection] return [info.obj for info in self.collection]
@@ -198,7 +197,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__: if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue continue
if isinstance(obj, type): if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj) class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector): if issubclass(obj, BECConnector):
class_info.is_connector = True class_info.is_connector = True
if issubclass(obj, BECWidget): if issubclass(obj, BECWidget):
+1 -1
View File
@@ -195,7 +195,7 @@ class RPCServer:
return return
self._broadcasted_data = data self._broadcasted_data = data
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}") logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd( self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id), MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)}, msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
+1 -1
View File
@@ -1,5 +1,5 @@
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtGui import QCloseEvent from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
+39 -65
View File
@@ -31,7 +31,6 @@ class SidePanel(QWidget):
panel_max_width: int = 200, panel_max_width: int = 200,
animation_duration: int = 200, animation_duration: int = 200,
animations_enabled: bool = True, animations_enabled: bool = True,
show_toolbar: bool = True,
): ):
super().__init__(parent=parent) super().__init__(parent=parent)
@@ -41,7 +40,6 @@ class SidePanel(QWidget):
self._panel_max_width = panel_max_width self._panel_max_width = panel_max_width
self._animation_duration = animation_duration self._animation_duration = animation_duration
self._animations_enabled = animations_enabled self._animations_enabled = animations_enabled
self._show_toolbar = show_toolbar
self._panel_width = 0 self._panel_width = 0
self._panel_height = 0 self._panel_height = 0
@@ -73,14 +71,13 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5) self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width) self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation in ("left", "right"): if self._orientation == "left":
if self._show_toolbar: self.main_layout.addWidget(self.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._orientation == "left":
self.main_layout.addWidget(self.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget) self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width") self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -105,13 +102,11 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width) self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top": if self._orientation == "top":
if self._show_toolbar: self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container) self.main_layout.addWidget(self.container)
else: else:
self.main_layout.addWidget(self.container) self.main_layout.addWidget(self.container)
if self._show_toolbar: self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget) self.container.layout.addWidget(self.stack_widget)
@@ -238,24 +233,21 @@ class SidePanel(QWidget):
def add_menu( def add_menu(
self, self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget, widget: QWidget,
action_id: str | None = None,
icon_name: str | None = None,
tooltip: str | None = None,
title: str | None = None, title: str | None = None,
) -> int: ):
""" """
Add a menu to the side panel. Add a menu to the side panel.
Args: 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. widget(QWidget): The widget to add to the panel.
action_id(str | None): The ID of the action. Optional if no toolbar action is needed. title(str): The title of the panel.
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: top-level container for the stacked page
container_widget = QWidget() container_widget = QWidget()
@@ -286,35 +278,32 @@ class SidePanel(QWidget):
index = self.stack_widget.count() index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget) self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided # Add an action to the toolbar
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)
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True) self.toolbar.add_action(action_id, action, target_widget=self)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool): def on_action_toggled(checked: bool):
if self.switching_actions: if self.switching_actions:
return return
if checked: if checked:
if self.current_action and self.current_action != action.action: if self.current_action and self.current_action != action.action:
self.switching_actions = True self.switching_actions = True
self.current_action.setChecked(False) self.current_action.setChecked(False)
self.switching_actions = False self.switching_actions = False
self.current_action = action.action self.current_action = action.action
if not self.panel_visible: if not self.panel_visible:
self.show_panel(index) self.show_panel(index)
else:
self.switch_to(index)
else: else:
if self.current_action == action.action: self.switch_to(index)
self.current_action = None else:
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
############################################ ############################################
@@ -343,56 +332,41 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.add_side_menus() self.add_side_menus()
def add_side_menus(self): def add_side_menus(self):
# Example 1: With action, icon, and tooltip
widget1 = QWidget() widget1 = QWidget()
layout1 = QVBoxLayout(widget1) layout1 = QVBoxLayout(widget1)
for i in range(15): for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}")) layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu( self.side_panel.add_menu(
widget=widget1,
action_id="widget1", action_id="widget1",
icon_name="counter_1", icon_name="counter_1",
tooltip="Show Widget 1", tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel", title="Widget 1 Panel",
) )
# Example 2: With action, icon, and tooltip
widget2 = QWidget() widget2 = QWidget()
layout2 = QVBoxLayout(widget2) layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content")) layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu( self.side_panel.add_menu(
widget=widget2,
action_id="widget2", action_id="widget2",
icon_name="counter_2", icon_name="counter_2",
tooltip="Show Widget 2", tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel", title="Widget 2 Panel",
) )
# Example 3: With action, icon, and tooltip
widget3 = QWidget() widget3 = QWidget()
layout3 = QVBoxLayout(widget3) layout3 = QVBoxLayout(widget3)
for i in range(10): for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3")) layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu( self.side_panel.add_menu(
widget=widget3,
action_id="widget3", action_id="widget3",
icon_name="counter_3", icon_name="counter_3",
tooltip="Show Widget 3", tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel", 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 if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv) app = QApplication(sys.argv)
-86
View File
@@ -7,7 +7,6 @@ from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from typing import Dict, List, Literal, Tuple from typing import Dict, List, Literal, Tuple
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon from qtpy.QtGui import QAction, QColor, QIcon
@@ -32,8 +31,6 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# Ensure that icons are shown in menus (especially on macOS) # Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False) QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
@@ -176,10 +173,6 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled, filled=self.filled,
color=self.color, color=self.color,
) )
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable) self.action.setCheckable(self.checkable)
@@ -709,85 +702,6 @@ class ModularToolBar(QToolBar):
self.bundles[bundle_id].append(action_id) self.bundles[bundle_id].append(action_id)
self.update_separators() 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): def contextMenuEvent(self, event):
""" """
Overrides the context menu event to show toolbar actions with checkboxes and icons. Overrides the context menu event to show toolbar actions with checkboxes and icons.
+4 -5
View File
@@ -2,14 +2,13 @@ from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6 from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice 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.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
if PYSIDE6: if PYSIDE6:
from qtpy.QtUiTools import QUiLoader from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader): class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None): def __init__(self, baseinstance, custom_widgets: dict | None = None):
@@ -31,9 +30,9 @@ class UILoader:
def __init__(self, parent=None): def __init__(self, parent=None):
self.parent = parent self.parent = parent
self.custom_widgets = ( widgets = get_custom_classes("bec_widgets").classes
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict() self.custom_widgets = {widget.__name__: widget for widget in widgets}
if PYSIDE6: if PYSIDE6:
self.loader = self.load_ui_pyside6 self.loader = self.load_ui_pyside6
@@ -163,11 +163,8 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar", tooltip="Add Circular ProgressBar",
filled=True, filled=True,
), ),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction( "log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
tooltip="Add LogPanel - Disabled",
filled=True,
), ),
}, },
), ),
@@ -233,11 +230,9 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect( self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar") lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
) )
# FIXME temporarily disabled -> issue #644 self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False) lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect( )
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
# Icons # Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all) self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
@@ -53,7 +53,7 @@ class LayoutManagerWidget(QWidget):
self, self,
widget: QWidget | str, widget: QWidget | str,
row: int | None = None, row: int | None = None,
col: int | None = None, col: Optional[int] = None,
rowspan: int = 1, rowspan: int = 1,
colspan: int = 1, colspan: int = 1,
shift_existing: bool = True, shift_existing: bool = True,
@@ -138,39 +138,6 @@ class LayoutManagerWidget(QWidget):
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget] ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position # 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": if position == "left":
new_row = ref_row new_row = ref_row
new_col = ref_col - 1 new_col = ref_col - 1
@@ -1,115 +0,0 @@
import sys
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
class HoverWidget(QWidget):
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
super().__init__(parent)
self._simple = simple
self._full = full
self._full.setVisible(False)
self._tooltip = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(simple)
def enterEvent(self, event):
# suppress empty-label tooltips for labels
if isinstance(self._full, QLabel) and not self._full.text():
return
if self._tooltip is None: # first time only
self._tooltip = WidgetTooltip(self._full)
self._full.setVisible(True)
centre = self.mapToGlobal(self.rect().center())
self._tooltip.show_above(centre)
super().enterEvent(event)
def leaveEvent(self, event):
if self._tooltip and self._tooltip.isVisible():
self._tooltip.hide()
super().leaveEvent(event)
def close(self):
if self._tooltip:
self._tooltip.close()
self._tooltip.deleteLater()
self._tooltip = None
super().close()
################################################################################
# Demo
# Just a simple example to show how the HoverWidget can be used to display
# a tooltip with a full widget inside (two different widgets are used
# for the simple and full versions).
################################################################################
class DemoSimpleWidget(QLabel): # pragma: no cover
"""A simple widget to be used as a trigger for the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setText("Hover me for a preview!")
class DemoFullWidget(QProgressBar): # pragma: no cover
"""A full widget to be shown in the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setRange(0, 100)
self.setValue(75)
self.setFixedWidth(320)
self.setFixedHeight(30)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = QWidget()
window.layout = QHBoxLayout(window)
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
window.layout.addWidget(hover_widget)
window.show()
sys.exit(app.exec_())
@@ -1,110 +0,0 @@
from qtpy.QtCore import QTimer
from qtpy.QtGui import QFontMetrics, QPainter
from qtpy.QtWidgets import QLabel
class ScrollLabel(QLabel):
"""A QLabel that scrolls its text horizontally across the widget."""
def __init__(self, parent=None, speed_ms=30, step_px=1, delay_ms=2000):
super().__init__(parent=parent)
self._offset = 0
self._text_width = 0
# scrolling timer (runs continuously once started)
self._timer = QTimer(self)
self._timer.setInterval(speed_ms)
self._timer.timeout.connect(self._scroll)
# delaybeforescroll timer (singleshot)
self._delay_timer = QTimer(self)
self._delay_timer.setSingleShot(True)
self._delay_timer.setInterval(delay_ms)
self._delay_timer.timeout.connect(self._timer.start)
self._step_px = step_px
def setText(self, text):
"""
Overridden to ensure that new text replaces the current one
immediately.
If the label was already scrolling (or in its delay phase),
the next message starts **without** the extra delay.
"""
# Determine whether the widget was already in a scrolling cycle
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
super().setText(text)
fm = QFontMetrics(self.font())
self._text_width = fm.horizontalAdvance(text)
self._offset = 0
# Skip the delay when we were already scrolling
self._update_timer(skip_delay=was_scrolling)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_timer()
def _update_timer(self, *, skip_delay: bool = False):
"""
Decide whether to start or stop scrolling.
If the text is wider than the visible area, start a singleshot
delay timer (2s by default). Scrolling begins only after this
delay. Any change (resize or new text) restarts the logic.
"""
needs_scroll = self._text_width > self.width()
if needs_scroll:
# Reset any running timers
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
self._offset = 0
# Start scrolling immediately when we should skip the delay,
# otherwise apply the configured delay_ms interval
if skip_delay:
self._timer.start()
else:
self._delay_timer.start()
else:
if self._delay_timer.isActive():
self._delay_timer.stop()
if self._timer.isActive():
self._timer.stop()
self.update()
def _scroll(self):
self._offset += self._step_px
if self._offset >= self._text_width:
self._offset = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
text = self.text()
if not text:
return
fm = QFontMetrics(self.font())
y = (self.height() + fm.ascent() - fm.descent()) // 2
if self._text_width <= self.width():
painter.drawText(0, y, text)
else:
x = -self._offset
gap = 50 # space between repeating text blocks
while x < self.width():
painter.drawText(x, y, text)
x += self._text_width + gap
def cleanup(self):
"""Stop all timers to prevent memory leaks."""
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
@@ -1,31 +1,17 @@
from __future__ import annotations
import os import os
from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QSize
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
import bec_widgets import bec_widgets
from bec_widgets.utils import UILoader from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -33,8 +19,6 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow): class BECMainWindow(BECWidget, QMainWindow):
RPC = False RPC = False
PLUGIN = False PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__( def __init__(
self, self,
@@ -48,19 +32,10 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs) super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance() self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title) self.setWindowTitle(window_title)
self._init_ui() self._init_ui()
self._connect_to_theme_change() self._connect_to_theme_change()
# Connections to BEC Notifications
self.bec_dispatcher.connect_slot(
self.display_client_message, MessageEndpoints.client_info()
)
################################################################################
# MainWindow Elements Initialization
################################################################################
def _init_ui(self): def _init_ui(self):
# Set the icon # Set the icon
@@ -68,189 +43,40 @@ class BECMainWindow(BECWidget, QMainWindow):
# Set Menu and Status bar # Set Menu and Status bar
self._setup_menu_bar() self._setup_menu_bar()
self._init_status_bar_widgets()
# BEC Specific UI # BEC Specific UI
self.display_app_id() self.display_app_id()
def _init_status_bar_widgets(self):
"""
Prepare the BEC specific widgets in the status bar.
"""
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
wrapper = QWidget()
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self): def _init_bec_icon(self):
icon = self.app.windowIcon() icon = self.app.windowIcon()
if icon.isNull(): if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon() icon = QIcon()
icon.addFile( icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48), size=QSize(48, 48),
) )
self.app.setWindowIcon(icon) self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file): def load_ui(self, ui_file):
loader = UILoader(self) loader = UILoader(self)
self.ui = loader.loader(ui_file) self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui) self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str: def _fetch_theme(self) -> str:
return self.app.theme.theme return self.app.theme.theme
@@ -338,64 +164,14 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs) help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report) help_menu.addAction(bug_report)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self._app_id_label.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):
"""
Display a client message in the status bar.
Args:
msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty.
"""
message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if expiration and expiration > 0:
self._client_info_expire_timer.start(int(expiration * 1000))
################################################################################
# General and Cleanup Methods
################################################################################
@SafeSlot(str) @SafeSlot(str)
def change_theme(self, theme: str): def change_theme(self, theme: str):
"""
Change the theme of the application.
Args:
theme(str): The theme to apply, either "light" or "dark".
"""
apply_theme(theme) apply_theme(theme)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def cleanup(self): def cleanup(self):
central_widget = self.centralWidget() central_widget = self.centralWidget()
if central_widget is not None: central_widget.close()
central_widget.close() central_widget.deleteLater()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget): if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method # if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow # of all widgets whose parent is the current BECMainWindow
@@ -406,39 +182,8 @@ class BECMainWindow(BECWidget, QMainWindow):
child.cleanup() child.cleanup()
child.close() child.close()
child.deleteLater() child.deleteLater()
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup() super().cleanup()
class UILaunchWindow(BECMainWindow): class UILaunchWindow(BECMainWindow):
RPC = True RPC = True
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())
@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from pydantic import field_validator from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ### ### QtSlots ###
@SafeSlot(str) @Slot(str)
def set_device(self, device: str): def set_device(self, device: str):
""" """
Set the device. Set the device.
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
else: else:
logger.warning(f"Device {device} is not in the filtered selection.") logger.warning(f"Device {device} is not in the filtered selection.")
@SafeSlot() @Slot()
def update_devices_from_filters(self): def update_devices_from_filters(self):
"""Update the devices based on the current filter selection """Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False, in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
self.devices = [device.name for device in devs] self.devices = [device.name for device in devs]
self.set_device(current_device) self.set_device(current_device)
@SafeSlot(list) @Slot(list)
def set_available_devices(self, devices: list[str]): def set_available_devices(self, devices: list[str]):
""" """
Set the devices. If a device in the list is not valid, it will not be considered. Set the devices. If a device in the list is not valid, it will not be considered.
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ### ### QtProperties ###
@SafeProperty( @Property(
"QStringList", "QStringList",
doc="List of devices. If updated, it will disable the apply filters property.", doc="List of devices. If updated, it will disable the apply filters property.",
) )
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value self.config.devices = value
FilterIO.set_selection(widget=self, selection=value) FilterIO.set_selection(widget=self, selection=value)
@SafeProperty(str) @Property(str)
def default(self): def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection.""" """Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default return self.config.default
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value self.config.default = value
WidgetIO.set_value(widget=self, value=value) WidgetIO.set_value(widget=self, value=value)
@SafeProperty(bool) @Property(bool)
def apply_filter(self): def apply_filter(self):
"""Apply the filters on the devices.""" """Apply the filters on the devices."""
return self.config.apply_filter return self.config.apply_filter
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value self.config.apply_filter = value
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def filter_to_device(self): def filter_to_device(self):
"""Include devices in filters.""" """Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter return BECDeviceFilter.DEVICE in self.device_filter
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE) self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def filter_to_positioner(self): def filter_to_positioner(self):
"""Include devices of type Positioner in filters.""" """Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter return BECDeviceFilter.POSITIONER in self.device_filter
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER) self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def filter_to_signal(self): def filter_to_signal(self):
"""Include devices of type Signal in filters.""" """Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter return BECDeviceFilter.SIGNAL in self.device_filter
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL) self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def filter_to_computed_signal(self): def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters.""" """Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL) self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def readout_monitored(self): def readout_monitored(self):
"""Include devices with readout priority Monitored in filters.""" """Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter return ReadoutPriority.MONITORED in self.readout_filter
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED) self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def readout_baseline(self): def readout_baseline(self):
"""Include devices with readout priority Baseline in filters.""" """Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter return ReadoutPriority.BASELINE in self.readout_filter
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE) self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def readout_async(self): def readout_async(self):
"""Include devices with readout priority Async in filters.""" """Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter return ReadoutPriority.ASYNC in self.readout_filter
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC) self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def readout_continuous(self): def readout_continuous(self):
"""Include devices with readout priority continuous in filters.""" """Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS) self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters() self.update_devices_from_filters()
@SafeProperty(bool) @Property(bool)
def readout_on_request(self): def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters.""" """Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter return ReadoutPriority.ON_REQUEST in self.readout_filter
@@ -397,7 +397,7 @@ class DeviceInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
""" """
self.validate_device(device) self.validate_device(device)
dev = getattr(self.dev, device, None) dev = getattr(self.dev, device.lower(), None)
if dev is None: if dev is None:
raise ValueError( raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device." f"Device {device} is not found in the device manager {self.dev} as enabled device."
@@ -1,12 +1,11 @@
from bec_lib.callback_handler import EventType from bec_lib.callback_handler import EventType
from bec_lib.device import Signal from bec_lib.device import Signal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property from qtpy.QtCore import Property, Slot
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -37,20 +36,18 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals", Kind.config: "include_config_signals",
} }
def __init__( def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
self, if config is None:
client=None, config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
config: DeviceSignalInputBaseConfig | dict | None = None, else:
gui_id: str = None, if isinstance(config, dict):
**kwargs, config = DeviceSignalInputBaseConfig(**config)
): self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
self.config = self._process_config_input(config)
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
self._device = None self._device = None
self.get_bec_shortcuts() self.get_bec_shortcuts()
self._signal_filter = set() self._signal_filter = []
self._signals = [] self._signals = []
self._hinted_signals = [] self._hinted_signals = []
self._normal_signals = [] self._normal_signals = []
@@ -61,7 +58,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ### ### Qt Slots ###
@SafeSlot(str) @Slot(str)
def set_signal(self, signal: str): def set_signal(self, signal: str):
""" """
Set the signal. Set the signal.
@@ -77,10 +74,10 @@ class DeviceSignalInputBase(BECWidget):
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}." f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
) )
@SafeSlot(str) @Slot(str)
def set_device(self, device: str | None): def set_device(self, device: str | None):
""" """
Set the device. If device is not valid, device will be set to None which happens Set the device. If device is not valid, device will be set to None which happpens
Args: Args:
device(str): device name. device(str): device name.
@@ -91,8 +88,8 @@ class DeviceSignalInputBase(BECWidget):
self._device = device self._device = device
self.update_signals_from_filters() self.update_signals_from_filters()
@SafeSlot(dict, dict) @Slot(dict, dict)
@SafeSlot() @Slot()
def update_signals_from_filters( def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None self, content: dict | None = None, metadata: dict | None = None
): ):
@@ -105,40 +102,41 @@ class DeviceSignalInputBase(BECWidget):
""" """
self.config.signal_filter = self.signal_filter self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access # pylint: disable=protected-access
if not self.validate_device(self._device): self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
self._device = None self._device = None
self.config.device = self._device self.config.device = self._device
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return return
device = self.get_device_object(self._device) device = self.get_device_object(self._device)
device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals # See above convention for Signals and ComputedSignals
if isinstance(device, Signal): if isinstance(device, Signal):
self._signals = [(self._device, {})] self._signals = [self._device]
self._hinted_signals = [(self._device, {})] FilterIO.set_selection(widget=self, selection=[self._device])
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return return
device_info = device._info["signals"]
def _update(kind: Kind): if Kind.hinted in self.signal_filter:
return FilterIO.update_with_kind( hinted_signals = [
widget=self, signal
kind=kind, for signal, signal_info in device_info.items()
signal_filter=self.signal_filter, if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
device_info=device_info, ]
device_name=self._device, self._hinted_signals = hinted_signals
) if Kind.normal in self.signal_filter:
normal_signals = [
self._hinted_signals = _update(Kind.hinted) signal
self._normal_signals = _update(Kind.normal) for signal, signal_info in device_info.items()
self._config_signals = _update(Kind.config) 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._signals = self._hinted_signals + self._normal_signals + self._config_signals self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals) FilterIO.set_selection(widget=self, selection=self.signals)
@@ -166,9 +164,9 @@ class DeviceSignalInputBase(BECWidget):
@include_hinted_signals.setter @include_hinted_signals.setter
def include_hinted_signals(self, value: bool): def include_hinted_signals(self, value: bool):
if value: if value:
self._signal_filter.add(Kind.hinted) self._signal_filter.append(Kind.hinted)
else: else:
self._signal_filter.discard(Kind.hinted) self._signal_filter.remove(Kind.hinted)
self.update_signals_from_filters() self.update_signals_from_filters()
@Property(bool) @Property(bool)
@@ -179,9 +177,9 @@ class DeviceSignalInputBase(BECWidget):
@include_normal_signals.setter @include_normal_signals.setter
def include_normal_signals(self, value: bool): def include_normal_signals(self, value: bool):
if value: if value:
self._signal_filter.add(Kind.normal) self._signal_filter.append(Kind.normal)
else: else:
self._signal_filter.discard(Kind.normal) self._signal_filter.remove(Kind.normal)
self.update_signals_from_filters() self.update_signals_from_filters()
@Property(bool) @Property(bool)
@@ -192,9 +190,9 @@ class DeviceSignalInputBase(BECWidget):
@include_config_signals.setter @include_config_signals.setter
def include_config_signals(self, value: bool): def include_config_signals(self, value: bool):
if value: if value:
self._signal_filter.add(Kind.config) self._signal_filter.append(Kind.config)
else: else:
self._signal_filter.discard(Kind.config) self._signal_filter.remove(Kind.config)
self.update_signals_from_filters() self.update_signals_from_filters()
### Properties and Methods ### ### Properties and Methods ###
@@ -252,7 +250,7 @@ class DeviceSignalInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
""" """
self.validate_device(device) self.validate_device(device)
dev = getattr(self.dev, device, None) dev = getattr(self.dev, device.lower(), None)
if dev is None: if dev is None:
logger.warning(f"Device {device} not found in devicemanager.") logger.warning(f"Device {device} not found in devicemanager.")
return None return None
@@ -278,14 +276,6 @@ class DeviceSignalInputBase(BECWidget):
Args: Args:
signal(str): Signal to validate. signal(str): Signal to validate.
""" """
for entry in self.signals: if signal in self.signals:
if isinstance(entry, tuple): return True
entry = entry[0]
if entry == signal:
return True
return False return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)
@@ -22,19 +22,14 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
config: Device input configuration. config: Device input configuration.
gui_id: GUI ID. gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name. default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
""" """
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt" ICON_NAME = "list_alt"
PLUGIN = True PLUGIN = True
device_selected = Signal(str) device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal() device_config_update = Signal()
def __init__( def __init__(
@@ -145,31 +140,11 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
""" """
if self.validate_device(input_text) is True: if self.validate_device(input_text) is True:
self._is_valid_input = True self._is_valid_input = True
self.device_selected.emit(input_text) self.device_selected.emit(input_text.lower())
else: else:
self._is_valid_input = False self._is_valid_input = False
self.device_reset.emit()
self.update() self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
@@ -24,15 +24,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client: BEC client object. client: BEC client object.
config: Device input configuration. config: Device input configuration.
gui_id: GUI ID. gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name. default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
""" """
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
device_selected = Signal(str) device_selected = Signal(str)
device_config_update = Signal() device_config_update = Signal()
@@ -55,7 +51,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
**kwargs, **kwargs,
): ):
self._callback_id = None self._callback_id = None
self.__is_valid_input = False self._is_valid_input = False
self._accent_colors = get_accent_colors() self._accent_colors = get_accent_colors()
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.completer = QCompleter(self) self.completer = QCompleter(self)
@@ -99,20 +95,6 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self.textChanged.connect(self.check_validity) self.textChanged.connect(self.check_validity)
self.check_validity(self.text()) self.check_validity(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
def on_device_update(self, action: str, content: dict) -> None: def on_device_update(self, action: str, content: dict) -> None:
""" """
Callback for device update events. Triggers the device_update signal. Callback for device update events. Triggers the device_update signal.
@@ -165,7 +147,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
""" """
if self.validate_device(input_text) is True: if self.validate_device(input_text) is True:
self._is_valid_input = True self._is_valid_input = True
self.device_selected.emit(input_text) self.device_selected.emit(input_text.lower())
else: else:
self._is_valid_input = False self._is_valid_input = False
self.update() self.update()
@@ -1,13 +1,11 @@
from bec_lib.device import Positioner from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtWidgets import QComboBox, QSizePolicy from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase, DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
) )
@@ -25,11 +23,8 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
""" """
USER_ACCESS = ["set_signal", "set_device", "signals"]
ICON_NAME = "list_alt" ICON_NAME = "list_alt"
PLUGIN = True PLUGIN = True
RPC = True
device_signal_changed = Signal(str) device_signal_changed = Signal(str)
@@ -37,7 +32,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self, self,
parent=None, parent=None,
client=None, client=None,
config: DeviceSignalInputBaseConfig | None = None, config: DeviceSignalInputBase = None,
gui_id: str | None = None, gui_id: str | None = None,
device: str | None = None, device: str | None = None,
signal_filter: str | list[str] | None = None, signal_filter: str | list[str] | None = None,
@@ -67,13 +62,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None: if default is not None:
self.set_signal(default) self.set_signal(default)
@SafeSlot() def update_signals_from_filters(self):
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox""" """Update the filters for the combobox"""
super().update_signals_from_filters(content, metadata) super().update_signals_from_filters()
# pylint: disable=protected-access # pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler: if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0: if len(self._config_signals) > 0:
@@ -90,15 +81,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals") self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False) self.model().item(0).setEnabled(False)
@SafeSlot() @Slot(str)
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str)
def on_text_changed(self, text: str): def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name. For a positioner, the readback value has to be renamed to the device name.
@@ -110,7 +93,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return return
if self.validate_signal(text) is False: if self.validate_signal(text) is False:
return return
self.device_signal_changed.emit(text) if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -24,12 +24,9 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
""" """
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
device_signal_changed = Signal(str) device_signal_changed = Signal(str)
PLUGIN = True PLUGIN = True
RPC = True
ICON_NAME = "vital_signs" ICON_NAME = "vital_signs"
def __init__( def __init__(
@@ -44,7 +41,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: str | None = None, arg_name: str | None = None,
**kwargs, **kwargs,
): ):
self.__is_valid_input = False self._is_valid_input = False
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._accent_colors = get_accent_colors() self._accent_colors = get_accent_colors()
self.completer = QCompleter(self) self.completer = QCompleter(self)
@@ -68,22 +65,8 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
self.set_device(device) self.set_device(device)
if default is not None: if default is not None:
self.set_signal(default) self.set_signal(default)
self.textChanged.connect(self.check_validity) self.textChanged.connect(self.validate_device)
self.check_validity(self.text()) self.validate_device(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
def get_current_device(self) -> object: def get_current_device(self) -> object:
""" """
@@ -148,9 +131,6 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
app = QApplication([]) app = QApplication([])
set_theme("dark") set_theme("dark")
@@ -158,12 +138,6 @@ if __name__ == "__main__": # pragma: no cover
widget.setFixedSize(200, 200) widget.setFixedSize(200, 200)
layout = QVBoxLayout() layout = QVBoxLayout()
widget.setLayout(layout) widget.setLayout(layout)
device_line_edit = DeviceComboBox() layout.addWidget(SignalLineEdit(device="samx"))
device_line_edit.filter_to_positioner = True
signal_line_edit = SignalLineEdit()
device_line_edit.device_selected.connect(signal_line_edit.set_device)
layout.addWidget(device_line_edit)
layout.addWidget(signal_line_edit)
widget.show() widget.show()
app.exec_() app.exec_()
@@ -89,7 +89,6 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout # Create and set main layout
self._init_UI() self._init_UI()
@@ -166,6 +165,7 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch() self.layout.addStretch()
def _add_metadata_form(self): def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form) self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText()) self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan) self.scan_selected.connect(self._metadata_form.update_with_new_scan)
@@ -203,37 +203,35 @@ class ScanControl(BECWidget, QWidget):
""" """
Requests the last executed scan parameters from BEC and restores them to the scan control widget. Requests the last executed scan parameters from BEC and restores them to the scan control widget.
""" """
self.last_scan_found = False enabled = self.toggle.checked
if not self.toggle.checked:
return
current_scan = self.comboBox_scan_selection.currentText() current_scan = self.comboBox_scan_selection.currentText()
history = self.client.connector.xread( if enabled:
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
)
for scan in history: for scan in history:
scan_data = scan.get("data") scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
if not scan_data: if scan_name == current_scan:
continue args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
"parameter"
if scan_data.scan_name != current_scan: ]["args"]
continue args_list = []
for key, value in args_dict.items():
ri = getattr(scan_data, "request_inputs", {}) or {} args_list.append(key)
args_list = ri.get("arg_bundle", []) args_list.extend(value)
if args_list and self.arg_box: if len(args_list) > 1 and self.arg_box is not None:
self.arg_box.set_parameters(args_list) self.arg_box.set_parameters(args_list)
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
inputs = ri.get("inputs", {}) "kwargs"
kwargs = ri.get("kwargs", {}) ]
merged = {**inputs, **kwargs} if kwargs and self.kwarg_boxes:
if merged and self.kwarg_boxes: for box in self.kwarg_boxes:
for box in self.kwarg_boxes: box.set_parameters(kwargs)
box.set_parameters(merged) self.last_scan_found = True
break
self.last_scan_found = True else:
break self.last_scan_found = False
else:
self.last_scan_found = False
@SafeProperty(str) @SafeProperty(str)
def current_scan(self): def current_scan(self):
@@ -0,0 +1,870 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""
import collections
import fcntl
import html
import os
import pty
import re
import signal
import sys
import time
import pyte
from pygments.token import Token
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot as Slot
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
"green": "#00CD00",
"brown": "#996633", # Brown, replacing the yellow
"blue": "#0000EE",
"magenta": "#CD00CD",
"cyan": "#00CDCD",
"white": "#E5E5E5",
"brightblack": "#7F7F7F",
"brightred": "#FF0000",
"brightgreen": "#00FF00",
"brightyellow": "#FFFF00",
"brightblue": "#5C5CFF",
"brightmagenta": "#FF00FF",
"brightcyan": "#00FFFF",
"brightwhite": "#FFFFFF",
}
control_keys_mapping = {
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
}
normal_keys_mapping = {
QtCore.Qt.Key_Return: b"\n",
QtCore.Qt.Key_Space: b" ",
QtCore.Qt.Key_Enter: b"\n",
QtCore.Qt.Key_Tab: b"\t",
QtCore.Qt.Key_Backspace: b"\x08",
QtCore.Qt.Key_Home: b"\x47",
QtCore.Qt.Key_End: b"\x4f",
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0e",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",
QtCore.Qt.Key_F2: b"\x1b\x32",
QtCore.Qt.Key_F3: b"\x1b\x33",
QtCore.Qt.Key_F4: b"\x1b\x34",
QtCore.Qt.Key_F5: b"\x1b\x35",
QtCore.Qt.Key_F6: b"\x1b\x36",
QtCore.Qt.Key_F7: b"\x1b\x37",
QtCore.Qt.Key_F8: b"\x1b\x38",
QtCore.Qt.Key_F9: b"\x1b\x39",
QtCore.Qt.Key_F10: b"\x1b\x30",
QtCore.Qt.Key_F11: b"\x45",
QtCore.Qt.Key_F12: b"\x46",
}
def QtKeyToAscii(event):
"""
Convert the Qt key event to the corresponding ASCII sequence for
the terminal. This works fine for standard alphanumerical characters, but
most other characters require terminal specific control sequences.
The conversion below works for TERM="linux" terminals.
"""
if sys.platform == "darwin":
# special case for MacOS
# /!\ Qt maps ControlModifier to CMD
# CMD-C, CMD-V for copy/paste
# CTRL-C and other modifiers -> key mapping
if event.modifiers() == QtCore.Qt.MetaModifier:
if event.key() == Qt.Key_Backspace:
return control_keys_mapping.get(Qt.Key_W)
return control_keys_mapping.get(event.key())
elif event.modifiers() == QtCore.Qt.ControlModifier:
if event.key() == Qt.Key_C:
# copy
return "copy"
elif event.key() == Qt.Key_V:
# paste
return "paste"
return None
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
if event.modifiers() == QtCore.Qt.ControlModifier:
return control_keys_mapping.get(event.key())
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, cols, rows, historyLength):
super().__init__(cols, rows, historyLength, ratio=1 / rows)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request (for example),
this can be for other requests
"""
try:
os.write(self._fd, data.encode("utf-8"))
except (IOError, OSError):
pass
def resize(self, lines, columns):
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return # No changes.
self.dirty.clear()
self.dirty.update(range(lines))
self.save_cursor()
if lines < self.lines:
if lines <= self.cursor.y:
nlines_to_move_up = self.lines - lines
for i in range(nlines_to_move_up):
line = self.buffer[i] # .pop(0)
self.history.top.append(line)
self.cursor_position(0, 0)
self.delete_lines(nlines_to_move_up)
self.restore_cursor()
self.cursor.y -= nlines_to_move_up
else:
self.restore_cursor()
self.lines, self.columns = lines, columns
self.history = History(
self.history.top,
self.history.bottom,
1 / self.lines,
self.history.size,
self.history.position,
)
self.set_margins()
class Backend(QtCore.QObject):
"""
Poll Bash.
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
file descriptor of the Bash terminal.
"""
# Signals to communicate with ``_TerminalWidget``.
dataReady = pyqtSignal(object)
processExited = pyqtSignal()
def __init__(self, fd, cols, rows):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, cols, rows, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._fd_readable)
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
self.processExited.emit()
self.notifier.setEnabled(False)
return
# Feed output into Pyte's state machine and send the new screen
# output to the GUI
self.stream.feed(out)
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QWidget):
"""Container widget for the terminal text area"""
PLUGIN = True
ICON_NAME = "terminal"
prompt = pyqtSignal(bool)
def __init__(self, parent=None, cols=132):
super().__init__(parent)
self.term = _TerminalWidget(self, cols, rows=43)
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
self.scroll_bar = QScrollBar(Qt.Vertical, self)
# self.scroll_bar.hide()
layout = QHBoxLayout(self)
layout.addWidget(self.term)
layout.addWidget(self.scroll_bar)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
pal = QPalette()
self.set_bgcolor(pal.window().color())
self.set_fgcolor(pal.windowText().color())
self.term.set_scroll_bar(self.scroll_bar)
self.set_cmd("bec --nogui")
self._check_designer_timer = QTimer()
self._check_designer_timer.timeout.connect(self.check_designer)
self._check_designer_timer.start(1000)
def minimumSizeHint(self):
size = self.term.sizeHint()
size.setWidth(size.width() + self.scroll_bar.width())
return size
def sizeHint(self):
return self.minimumSizeHint()
def check_designer(self, calls={"n": 0}):
calls["n"] += 1
if self.term.fd is not None:
# already started
self._check_designer_timer.stop()
elif self.window().windowTitle().endswith("[Preview]"):
# assuming Designer preview -> start
self._check_designer_timer.stop()
self.term.start()
elif calls["n"] >= 3:
# assuming not in Designer -> stop checking
self._check_designer_timer.stop()
def get_rows(self):
return self.term.rows
def set_rows(self, rows):
self.term.rows = rows
self.adjustSize()
self.updateGeometry()
def get_cols(self):
return self.term.cols
def set_cols(self, cols):
self.term.cols = cols
self.adjustSize()
self.updateGeometry()
def get_bgcolor(self):
return QColor.fromString(self.term.bg_color)
def set_bgcolor(self, color):
self.term.bg_color = color.name(QColor.HexRgb)
def get_fgcolor(self):
return QColor.fromString(self.term.fg_color)
def set_fgcolor(self, color):
self.term.fg_color = color.name(QColor.HexRgb)
def get_cmd(self):
return self.term._cmd
def set_cmd(self, cmd):
self.term._cmd = cmd
if self.term.fd is None:
# not started yet
self.term.clear()
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
def start(self, deactivate_ctrl_d=True):
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text, hit_return=False):
"""Push some text to the terminal"""
return self.term.push(text, hit_return=hit_return)
def execute_command(self, command):
self.push(command, hit_return=True)
def set_prompt_tokens(self, *tokens):
"""Prepare regexp to identify prompt, based on tokens
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
"""
regex_parts = []
for token_type, token_value in tokens:
if token_type == Token.PromptNum: # Handle dynamic prompt number
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
else:
# Escape other prompt parts (e.g., "In [", "]: ")
if not token_value:
regex_parts.append(".+?") # arbitrary string
else:
regex_parts.append(re.escape(token_value))
# Combine into a single regex
prompt_pattern = "".join(regex_parts)
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
def terminate(self, timeout=10):
self.term.stop(timeout=timeout)
def send_ctrl_c(self, timeout=None):
self.term.send_ctrl_c(timeout)
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
cmd = pyqtProperty(str, get_cmd, set_cmd)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
prompt = pyqtSignal(bool)
def __init__(self, parent, cols=125, rows=50, **kwargs):
# regexp to match prompt
self._prompt_re = None
# last prompt
self._prompt_str = None
# process pid
self.pid = None
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
# command to execute
self._cmd = ""
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Default colors
pal = QPalette()
self._fg_color = pal.text().color().name()
self._bg_color = pal.base().color().name()
# Specify the terminal size in terms of lines and columns.
self._rows = rows
self._cols = cols
self.output = collections.deque()
super().__init__(parent)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_bar = None
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
self.setCursorWidth(char_width)
self.adjustSize()
self.updateGeometry()
self.update_stylesheet()
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, hexcolor):
self._bg_color = hexcolor
self.update_stylesheet()
@property
def fg_color(self):
return self._fg_color
@fg_color.setter
def fg_color(self, hexcolor):
self._fg_color = hexcolor
self.update_stylesheet()
def update_stylesheet(self):
self.setStyleSheet(
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
)
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, rows: int):
if self.backend is None:
# not initialized yet, ok to change
self._rows = rows
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change rows after console is started.")
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, cols: int):
if self.fd is None:
# not initialized yet, ok to change
self._cols = cols
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change cols after console is started.")
def start(self, deactivate_ctrl_d: bool = False):
self._deactivate_ctrl_d = deactivate_ctrl_d
self.update_term_size()
# Start the Bash process
self.pid, self.fd = self.fork_shell()
if self.fd:
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.cols, self.rows)
self.backend.dataReady.connect(self.data_ready)
self.backend.processExited.connect(self.process_exited)
else:
self.process_exited()
def process_exited(self):
self.fd = None
self.clear()
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
self.setReadOnly(True)
def send_ctrl_c(self, wait_prompt=True, timeout=None):
"""Send CTRL-C to the process
If wait_prompt=True (default), wait for a new prompt after CTRL-C
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
"""
os.kill(self.pid, signal.SIGINT)
if wait_prompt:
timeout_error = False
if timeout:
def set_timeout_error():
nonlocal timeout_error
timeout_error = True
timeout_timer = QTimer()
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
while self._prompt_str is None:
QApplication.instance().process_events()
if timeout_error:
raise TimeoutError(
f"CTRL-C: could not get back to prompt after {timeout} seconds."
)
def _is_running(self):
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
return True
return False
def stop(self, kill=True, timeout=None):
"""Stop the running process
SIGTERM is the default signal for terminating processes.
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
"""
# try to exit gracefully
os.kill(self.pid, signal.SIGTERM)
# wait until process is truly dead
t0 = time.perf_counter()
while self._is_running():
time.sleep(1)
if timeout is not None and time.perf_counter() - t0 > timeout:
# still alive after 'timeout' seconds
if kill:
# send SIGKILL and make a last check in loop
os.kill(self.pid, signal.SIGKILL)
kill = False
else:
# still running after timeout...
raise TimeoutError(
f"Could not terminate process with pid: {self.pid} within timeout"
)
self.process_exited()
def data_ready(self, screen):
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
This method is triggered via a signal from ``Backend``.
"""
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def minimumSizeHint(self):
"""Return minimum size for current cols and rows"""
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
width = char_width * self.cols
height = char_height * self.rows
return QSize(width, height)
def sizeHint(self):
return self.minimumSizeHint()
def set_scroll_bar(self, scroll_bar):
self.scroll_bar = scroll_bar
self.scroll_bar.setMinimum(0)
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": -1}):
if self.backend is None:
return
if old["value"] == -1:
old["value"] = self.scroll_bar.maximum()
if value <= old["value"]:
# scroll up
# value is number of lines from the start
nlines = old["value"] - value
# history ratio gives prev_page == 1 line
for i in range(nlines):
self.backend.screen.prev_page()
else:
# scroll down
nlines = value - old["value"]
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.redraw_screen()
def adjust_scroll_bar(self):
sb = self.scroll_bar
sb.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
sb.setMaximum(tmp if tmp > 0 else 0)
sb.setSliderPosition(tmp if tmp > 0 else 0)
# if tmp > 0:
# # show scrollbar, but delayed - prevent recursion with widget size change
# QTimer.singleShot(0, scrollbar.show)
# else:
# QTimer.singleShot(0, scrollbar.hide)
sb.valueChanged.connect(self.scroll_value_change)
def write(self, data):
try:
os.write(self.fd, data)
except (IOError, OSError):
self.process_exited()
@Slot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
if self.fd is None:
# not started
return
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
and event.modifiers() == QtCore.Qt.ControlModifier
and event.key() == QtCore.Qt.Key_D
):
return None
code = QtKeyToAscii(event)
if code == "copy":
# MacOS only: CMD-C handling
self.copy()
elif code == "paste":
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
self.write(code)
def push(self, text, hit_return=False):
"""
Write 'text' to terminal
"""
self.write(text.encode("utf-8"))
if hit_return:
self.write(b"\n")
def contextMenuEvent(self, event):
if self.fd is None:
return
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
if "opy" in action.text():
# redefine text without shortcut
# since it probably clashes with control codes (like CTRL-C etc)
action.setText("Copy")
continue
if "aste" in action.text():
# redefine text without shortcut
action.setText("Paste")
# paste -> have to insert with self.push
action.triggered.connect(self._push_clipboard)
continue
menu.removeAction(action)
menu.exec_(event.globalPos())
def _push_clipboard(self):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def move_cursor(self):
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
def mouseReleaseEvent(self, event):
if self.fd is None:
return
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
if clipboard.supportsSelection():
self.push(clipboard.text(QClipboard.Selection))
return None
elif event.button() == Qt.LeftButton:
# left button click
textCursor = self.textCursor()
if textCursor.selectedText():
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', move scrollbar to end
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
self.move_cursor()
return None
return super().mouseReleaseEvent(event)
def redraw_screen(self):
"""
Render the screen as formatted text into the widget.
"""
screen = self.backend.screen
# Clear the widget
if screen.dirty:
self.clear()
while len(self.output) < (max(screen.dirty) + 1):
self.output.append("")
while len(self.output) > (max(screen.dirty) + 1):
self.output.pop()
# Prepare the HTML output
for line_no in screen.dirty:
line = text = ""
style = old_style = ""
old_idx = 0
for idx, ch in screen.buffer[line_no].items():
text += " " * (idx - old_idx - 1)
old_idx = idx
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
text = ""
old_style = style
text += ch.data
if style:
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
# do a check at the cursor position:
# it is possible x pos > output line length,
# for example if last escape codes are "cursor forward" past end of text,
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
if line_no == screen.cursor.y:
llen = len(screen.buffer[line_no])
if llen < screen.cursor.x:
line += " " * (screen.cursor.x - llen)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
if self._prompt_re is not None:
text_buf = self.toPlainText()
prompt = self._prompt_re.search(text_buf)
if prompt is None:
if self._prompt_str:
self.prompt.emit(False)
self._prompt_str = None
else:
prompt_str = prompt.string.rstrip()
if prompt_str != self._prompt_str:
self._prompt_str = prompt_str
self.prompt.emit(True)
# did updates, all clean
screen.dirty.clear()
def update_term_size(self):
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
self._cols = int(self.width() / char_width)
self._rows = int(self.height() / char_height)
def resizeEvent(self, event):
self.update_term_size()
if self.fd:
self.backend.screen.resize(self._rows, self._cols)
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def wheelEvent(self, event):
if not self.fd:
return
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.redraw_screen()
def fork_shell(self):
"""
Fork the current process and execute bec in shell.
"""
try:
pid, fd = pty.fork()
except (IOError, OSError):
return False
if pid == 0:
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
os.putenv("COLUMNS", str(self.cols))
os.putenv("LINES", str(self.rows))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if not self._cmd:
self._cmd = os.environ["SHELL"]
cmd = self._cmd
if isinstance(cmd, str):
cmd = cmd.split()
try:
os.execvp(cmd[0], cmd)
except (IOError, OSError):
pass
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
return pid, fd
if __name__ == "__main__":
import os
import sys
from qtpy import QtGui, QtWidgets
# Create the Qt application and console.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole"
mainwin.setWindowTitle(title)
console = BECConsole(mainwin)
mainwin.setCentralWidget(console)
def check_prompt(at_prompt):
if at_prompt:
print("NEW PROMPT")
else:
print("EXECUTING SOMETHING...")
console.set_prompt_tokens(
(Token.OutPromptNum, ""),
(Token.Prompt, ""), # will match arbitrary string,
(Token.Prompt, " ["),
(Token.PromptNum, "3"),
(Token.Prompt, "/"),
(Token.PromptNum, "1"),
(Token.Prompt, "] "),
(Token.Prompt, "❯❯"),
)
console.prompt.connect(check_prompt)
console.start()
# Show widget and launch Qt's event loop.
mainwin.show()
sys.exit(app.exec_())
@@ -0,0 +1 @@
{'files': ['console.py']}
@@ -1,26 +1,30 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.editors.console.console import BECConsole
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
<widget class='WebConsole' name='web_console'> <widget class='BECConsole' name='bec_console'>
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
t = WebConsole(parent) t = BECConsole(parent)
return t return t
def domXml(self): def domXml(self):
@@ -30,10 +34,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BEC Console" return "BEC Console"
def icon(self): def icon(self):
return designer_material_icon(WebConsole.ICON_NAME) return designer_material_icon(BECConsole.ICON_NAME)
def includeFile(self): def includeFile(self):
return "web_console" return "bec_console"
def initialize(self, form_editor): def initialize(self, form_editor):
self._form_editor = form_editor self._form_editor = form_editor
@@ -45,10 +49,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None return self._form_editor is not None
def name(self): def name(self):
return "WebConsole" return "BECConsole"
def toolTip(self): def toolTip(self):
return "" return "A terminal-like vt100 widget."
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -2,9 +2,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QHBoxLayout, QHBoxLayout,
@@ -15,9 +13,7 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeSlot
_NOT_SET = object()
class DictBackedTableModel(QAbstractTableModel): class DictBackedTableModel(QAbstractTableModel):
@@ -29,7 +25,6 @@ class DictBackedTableModel(QAbstractTableModel):
data (list[list[str]]): list of key-value pairs to initialise with""" data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__() super().__init__()
self._data: list[list[str]] = data self._data: list[list[str]] = data
self._default = _NOT_SET
self._disallowed_keys: list[str] = [] self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@@ -50,31 +45,17 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole): def data(self, index, role=Qt.ItemDataRole):
if index.isValid(): if index.isValid():
if role in [ if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
Qt.ItemDataRole.DisplayRole, return str(self._data[index.row()][index.column()])
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.ToolTipRole,
]:
try:
return str(self._data[index.row()][index.column()])
except IndexError:
return None
def setData(self, index, value, role): def setData(self, index, value, role):
if role == Qt.ItemDataRole.EditRole: if role == Qt.ItemDataRole.EditRole:
if value in self._disallowed_keys or value in self._other_keys(index.row()): if value in self._disallowed_keys or value in self._other_keys(index.row()):
return False return False
self._data[index.row()][index.column()] = str(value) self._data[index.row()][index.column()] = str(value)
self.dataChanged.emit(index, index)
return True return True
return False return False
def replaceData(self, data: dict):
self.delete_rows(list(range(len(self._data))))
self.resetInternalData()
self._data = [[str(k), str(v)] for k, v in data.items()]
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
def update_disallowed_keys(self, keys: list[str]): def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used. """Set the list of keys which may not be used.
@@ -84,7 +65,7 @@ class DictBackedTableModel(QAbstractTableModel):
for i, item in enumerate(self._data): for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys: if item[0] in self._disallowed_keys:
self._data[i][0] = "" self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 1)) self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
def _other_keys(self, row: int): def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]] return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
@@ -113,74 +94,44 @@ class DictBackedTableModel(QAbstractTableModel):
@SafeSlot() @SafeSlot()
def add_row(self): def add_row(self):
self.insertRow(self.rowCount()) self.insertRow(self.rowCount())
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
@SafeSlot(list) @SafeSlot(list)
def delete_rows(self, rows: list[int]): def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct # delete from the end so indices stay correct
for row in sorted(rows, reverse=True): for row in sorted(rows, reverse=True):
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
self.removeRows(row, 1, QModelIndex()) self.removeRows(row, 1, QModelIndex())
def set_default(self, value: dict | None):
self._default = value
def dump_dict(self): def dump_dict(self):
if self._data in [[], [[]], [["", ""]]]: if self._data == [[]]:
if self._default is not _NOT_SET:
return self._default
return {} return {}
return dict(self._data) return dict(self._data)
def length(self):
return len(self._data)
class DictBackedTable(QWidget): class DictBackedTable(QWidget):
delete_rows = Signal(list) delete_rows = Signal(list)
data_changed = Signal(dict)
def __init__( def __init__(self, initial_data: list[list[str]]):
self,
parent: QWidget | None = None,
initial_data: list[list[str]] = [],
autoscale_to_data: bool = True,
):
"""Widget which uses a DictBackedTableModel to display an editable table """Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict. which can be extracted as a dict.
Args: Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with initial_data (list[list[str]]): list of key-value pairs to initialise with
""" """
super().__init__(parent) super().__init__()
self._layout = QHBoxLayout() self._layout = QHBoxLayout()
self.setLayout(self._layout) self.setLayout(self._layout)
self._layout.setContentsMargins(0, 0, 0, 0)
self._table_model = DictBackedTableModel(initial_data) self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView() self._table_view = QTreeView()
self._table_view.setModel(self._table_model) self._table_view.setModel(self._table_model)
self._min_lines = 3
self.set_height_in_lines(len(initial_data))
self._table_view.setSizePolicy( self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
) )
self._table_view.setAlternatingRowColors(True) self._table_view.setAlternatingRowColors(True)
self._table_view.setUniformRowHeights(True)
self._table_view.setWordWrap(False)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self.autoscale = autoscale_to_data
if self.autoscale:
self.data_changed.connect(self.scale_to_data)
self._layout.addWidget(self._table_view) self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
self._buttons = QVBoxLayout() self._buttons = QVBoxLayout()
self._button_holder.setLayout(self._buttons) self._layout.addLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+") self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row") self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-") self._remove_button = QPushButton("-")
@@ -191,21 +142,6 @@ class DictBackedTable(QWidget):
self._remove_button.clicked.connect(self.delete_selected_rows) self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows) self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
def set_default(self, value: dict | None):
self._table_model.set_default(value)
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@SafeSlot()
def clear(self):
self._table_model.replaceData({})
def replace_data(self, data: dict | None):
self._table_model.replaceData(data or {})
def delete_selected_rows(self): def delete_selected_rows(self):
"""Delete rows which are part of the selection model""" """Delete rows which are part of the selection model"""
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes() cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
@@ -224,29 +160,6 @@ class DictBackedTable(QWidget):
keys (list[str]): list of keys which are forbidden.""" keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys) self._table_model.update_disallowed_keys(keys)
def set_height_in_lines(self, lines: int):
self._table_view.setMaximumHeight(
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
)
@SafeSlot()
@SafeSlot(dict)
def scale_to_data(self, *_):
self.set_height_in_lines(self._table_model.length())
@SafeProperty(bool)
def autoscale(self): # type: ignore
return self._autoscale
@autoscale.setter
def autoscale(self, autoscale: bool):
self._autoscale = autoscale
if self._autoscale:
self.scale_to_data()
self.data_changed.connect(self.scale_to_data)
else:
self.data_changed.disconnect(self.scale_to_data)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
@@ -254,6 +167,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([]) app = QApplication([])
set_theme("dark") set_theme("dark")
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show() window.show()
app.exec() app.exec()
@@ -2,7 +2,7 @@ from __future__ import annotations
import sys import sys
from decimal import Decimal from decimal import Decimal
from math import copysign, inf, nextafter from math import inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt from annotated_types import Ge, Gt, Le, Lt
@@ -23,19 +23,16 @@ _MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal) T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]: def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
def _nextafter(x, y):
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
_min = _MININT if type_ is int else _MINFLOAT _min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT _max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata: for md in info.metadata:
if isinstance(md, Ge): if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore _min = type_(md.ge) # type: ignore
if isinstance(md, Gt): if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore _min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt): if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore _max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le): if isinstance(md, Le):
_max = type_(md.le) # type: ignore _max = type_(md.le) # type: ignore
return _min, _max # type: ignore return _min, _max # type: ignore
@@ -67,6 +64,4 @@ def field_default(info: FieldInfo):
def clearable_required(info: FieldInfo): def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or ( return type(None) in get_args(info.annotation) or info.is_required()
info.is_required() and info.default is PydanticUndefined
)
@@ -16,9 +16,6 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm): class ScanMetadata(PydanticModelForm):
RPC = False
def __init__( def __init__(
self, self,
parent=None, parent=None,
@@ -39,18 +36,15 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__ # self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists # so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame( self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
parent, "Additional metadata", expanded=False
)
self._additional_md_box_layout = QHBoxLayout() self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout) self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(parent, initial_extras or []) self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or "" self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_changed.connect(self.validate_form)
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box) self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata) self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -132,7 +126,6 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout) w.setLayout(layout)
scan_metadata = ScanMetadata( scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan", scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]], initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
) )
@@ -1,230 +0,0 @@
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_())
@@ -1 +0,0 @@
{'files': ['web_console.py']}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,9 @@ logger = bec_logger.logger
# noinspection PyDataclass # noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.") parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.") color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.") downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.") opacity: float | None = Field(1.0, description="The opacity of the image.")
@@ -40,7 +43,6 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
class ImageItem(BECConnector, pg.ImageItem): class ImageItem(BECConnector, pg.ImageItem):
RPC = True RPC = True
USER_ACCESS = [ USER_ACCESS = [
"color_map", "color_map",
@@ -67,13 +69,12 @@ class ImageItem(BECConnector, pg.ImageItem):
] ]
vRangeChangedManually = Signal(tuple) vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__( def __init__(
self, self,
config: Optional[ImageItemConfig] = None, config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None, gui_id: Optional[str] = None,
parent_image=None, # FIXME: rename to parent parent_image=None,
**kwargs, **kwargs,
): ):
if config is None: if config is None:
@@ -273,8 +274,6 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = [] self.buffer = []
self.max_len = 0 self.max_len = 0
def remove(self, emit: bool = True): def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
self.clear() self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())
@@ -1,37 +0,0 @@
import pyqtgraph as pg
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.plots.plot_base import BECViewBox
class ImageROIPlot(RoundedFrame):
"""
A widget for displaying an image with a region of interest (ROI) overlay.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.content_widget = pg.GraphicsLayoutWidget(self)
self.layout.addWidget(self.content_widget)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.content_widget.addItem(self.plot_item)
self.curve_color = "w"
self.apply_plot_widget_style()
def apply_theme(self, theme: str):
if theme == "dark":
self.curve_color = "w"
else:
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
super().apply_theme(theme)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.plot_item.vb.menu.close()
self.plot_item.vb.menu.deleteLater()
self.plot_item.ctrlMenu.close()
self.plot_item.ctrlMenu.deleteLater()
@@ -1,457 +0,0 @@
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,
QHBoxLayout,
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,
EllipticalROI,
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 ROILockButton(QToolButton):
"""Keeps its icon and checked state in sync with a single ROI."""
def __init__(self, roi: BaseROI, parent=None):
super().__init__(parent)
self.setCheckable(True)
self._roi = roi
self.clicked.connect(self._toggle)
roi.movableChanged.connect(lambda _: self._sync())
self._sync()
def _toggle(self):
# checked -> locked -> movable = False
self._roi.movable = not self.isChecked()
def _sync(self):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
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")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) ---
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
tb.add_action("Add Rect ROI", self.add_rect_action, self)
self._draw_actions["rect"] = self.add_rect_action
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Circle ROI", self.add_circle_action, self)
self._draw_actions["circle"] = self.add_circle_action
# --- Ellipse ROI draw action ---
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
self._draw_actions["ellipse"] = self.add_ellipse_action
for mode, act in self._draw_actions.items():
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
# 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)
# Lock/Unlock all ROIs
self.lock_all_action = MaterialIconAction(
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
)
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
self.lock_all_action.action.toggled.connect(_lock_all)
# 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' | 'ellipse' | None
self._roi_start_pos = None # QPointF in image coords
self._temp_roi = None # live ROI being resized while dragging
# 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):
# Update toolbar actions so that only the selected mode is checked
for m, act in self._draw_actions.items():
act.action.blockSignals(True)
act.action.setChecked(m == mode)
act.action.blockSignals(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 _on_draw_action_toggled(self, mode: str, checked: bool):
if checked:
# Activate selected mode
self._set_roi_draw_mode(mode)
else:
# If the active mode is being unchecked, clear mode
if self._roi_draw_mode == mode:
self._set_roi_draw_mode(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,
)
elif 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,
)
elif self._roi_draw_mode == "ellipse":
self._temp_roi = EllipticalROI(
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])
elif 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])
elif self._roi_draw_mode == "ellipse":
# Safeguard: enforce a minimum ellipse width/height of 2 px
min_dim = 2.0
w = dx if abs(dx) >= min_dim else math.copysign(min_dim, dx or 1.0)
h = dy if abs(dy) >= min_dim else math.copysign(min_dim, dy or 1.0)
self._temp_roi.setSize([w, h])
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
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
# check the global setting from the toolbar
if self.lock_all_action.action.isChecked():
roi.movable = False
# 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)
# --- actions widget (lock/unlock + delete) ---
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(3)
# lock / unlock toggle
lock_btn = ROILockButton(roi, parent=self)
actions_layout.addWidget(lock_btn)
# delete button
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)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# 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 _toggle_movable(self, roi: BaseROI):
"""
Toggle the `movable` property of the given ROI.
"""
roi.movable = not roi.movable
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
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_())
@@ -1,5 +1,5 @@
from bec_lib.device import ReadoutPriority from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt, QTimer from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
@@ -35,73 +35,25 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.device_combo_box.addItem("", None) self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("") self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device") self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setFixedWidth(150)
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False)) self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
# 2) Dimension combo box # 2) Dimension combo box
self.dim_combo_box = QComboBox(parent=self.target_widget) self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box.addItems(["auto", "1d", "2d"]) self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto") self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension") self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(100) self.dim_combo_box.setFixedWidth(60)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)) self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
self.device_combo_box.currentTextChanged.connect(self.connect_monitor) # Connect slots, a device will be connected upon change of any combobox
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot() @SafeSlot()
def connect_monitor(self, *args, **kwargs): def connect_monitor(self):
"""
Connect the target widget to the selected monitor based on the current device and dimension.
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
"""
dim = self.dim_combo_box.currentText() dim = self.dim_combo_box.currentText()
data = self.device_combo_box.currentData() self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
if isinstance(data, tuple):
self.target_widget.image(monitor=data, monitor_type="auto")
else:
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
@@ -11,31 +11,18 @@ class ImageProcessingToolbarBundle(ToolbarBundle):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs) super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget self.target_widget = target_widget
self.fft = MaterialIconAction( self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True)
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True)
)
self.log = MaterialIconAction(
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget
)
self.transpose = MaterialIconAction( self.transpose = MaterialIconAction(
icon_name="transform", icon_name="transform", tooltip="Transpose Image", checkable=True
tooltip="Transpose Image",
checkable=True,
parent=self.target_widget,
) )
self.right = MaterialIconAction( self.right = MaterialIconAction(
icon_name="rotate_right", icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
tooltip="Rotate image clockwise by 90 deg",
parent=self.target_widget,
) )
self.left = MaterialIconAction( self.left = MaterialIconAction(
icon_name="rotate_left", icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
tooltip="Rotate image counterclockwise by 90 deg",
parent=self.target_widget,
)
self.reset = MaterialIconAction(
icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget
) )
self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings")
self.add_action("fft", self.fft) self.add_action("fft", self.fft)
self.add_action("log", self.log) self.add_action("log", self.log)
@@ -91,8 +91,6 @@ class MultiWaveform(PlotBase):
"y_log.setter", "y_log.setter",
"legend_label_size", "legend_label_size",
"legend_label_size.setter", "legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# MultiWaveform Specific RPC Access # MultiWaveform Specific RPC Access
"highlighted_index", "highlighted_index",
"highlighted_index.setter", "highlighted_index.setter",
+5 -71
View File
@@ -112,11 +112,8 @@ class PlotBase(BECWidget, QWidget):
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight) self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._user_x_label = "" self._user_x_label = ""
self._x_label_suffix = "" self._x_label_suffix = ""
self._x_axis_units = ""
self._user_y_label = "" self._user_y_label = ""
self._y_label_suffix = "" self._y_label_suffix = ""
self._y_axis_units = ""
self._minimal_crosshair_precision = 3
# Plot Indicator Items # Plot Indicator Items
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
@@ -476,31 +473,12 @@ class PlotBase(BECWidget, QWidget):
self._x_label_suffix = suffix self._x_label_suffix = suffix
self._apply_x_label() self._apply_x_label()
@property
def x_label_units(self) -> str:
"""
The units of the x-axis.
"""
return self._x_axis_units
@x_label_units.setter
def x_label_units(self, units: str):
"""
The units of the x-axis.
Args:
units(str): The units to set.
"""
self._x_axis_units = units
self._apply_x_label()
@property @property
def x_label_combined(self) -> str: def x_label_combined(self) -> str:
""" """
The final label shown on the axis = user portion + suffix + [units]. The final label shown on the axis = user portion + suffix.
""" """
units = f" [{self._x_axis_units}]" if self._x_axis_units else "" return self._user_x_label + self._x_label_suffix
return self._user_x_label + self._x_label_suffix + units
def _apply_x_label(self): def _apply_x_label(self):
""" """
@@ -543,31 +521,12 @@ class PlotBase(BECWidget, QWidget):
self._y_label_suffix = suffix self._y_label_suffix = suffix
self._apply_y_label() self._apply_y_label()
@property
def y_label_units(self) -> str:
"""
The units of the y-axis.
"""
return self._y_axis_units
@y_label_units.setter
def y_label_units(self, units: str):
"""
The units of the y-axis.
Args:
units(str): The units to set.
"""
self._y_axis_units = units
self._apply_y_label()
@property @property
def y_label_combined(self) -> str: def y_label_combined(self) -> str:
""" """
The final y label shown on the axis = user portion + suffix + [units]. The final y label shown on the axis = user portion + suffix.
""" """
units = f" [{self._y_axis_units}]" if self._y_axis_units else "" return self._user_y_label + self._y_label_suffix
return self._user_y_label + self._y_label_suffix + units
def _apply_y_label(self): def _apply_y_label(self):
""" """
@@ -979,9 +938,7 @@ class PlotBase(BECWidget, QWidget):
def hook_crosshair(self) -> None: def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots.""" """Hook the crosshair to all plots."""
if self.crosshair is None: if self.crosshair is None:
self.crosshair = Crosshair( self.crosshair = Crosshair(self.plot_item, precision=3)
self.plot_item, min_precision=self._minimal_crosshair_precision
)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed) self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked) self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed) self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
@@ -1009,29 +966,6 @@ class PlotBase(BECWidget, QWidget):
self.unhook_crosshair() self.unhook_crosshair()
@SafeProperty(
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
)
def minimal_crosshair_precision(self) -> int:
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
return self._minimal_crosshair_precision
@minimal_crosshair_precision.setter
def minimal_crosshair_precision(self, value: int):
"""
Set the minimum decimal places for crosshair when dynamic precision is enabled.
Args:
value(int): The minimum decimal places to set.
"""
value_int = max(0, int(value))
self._minimal_crosshair_precision = value_int
if self.crosshair is not None:
self.crosshair.min_precision = value_int
self.property_changed.emit("minimal_crosshair_precision", value_int)
@SafeSlot() @SafeSlot()
def reset(self) -> None: def reset(self) -> None:
"""Reset the plot widget.""" """Reset the plot widget."""
File diff suppressed because it is too large Load Diff
@@ -82,8 +82,6 @@ class ScatterWaveform(PlotBase):
"y_log.setter", "y_log.setter",
"legend_label_size", "legend_label_size",
"legend_label_size.setter", "legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# Scatter Waveform Specific RPC Access # Scatter Waveform Specific RPC Access
"main_curve", "main_curve",
"color_map", "color_map",
@@ -141,14 +141,6 @@
<header>bec_color_map_widget</header> <header>bec_color_map_widget</header>
</customwidget> </customwidget>
</customwidgets> </customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_name</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_name</tabstop>
<tabstop>z_entry</tabstop>
</tabstops>
<resources/> <resources/>
<connections> <connections>
<connection> <connection>
@@ -60,7 +60,6 @@ class AxisSettings(SettingWidget):
self.ui.y_grid, self.ui.y_grid,
self.ui.inner_axes, self.ui.inner_axes,
self.ui.outer_axes, self.ui.outer_axes,
self.ui.minimal_crosshair_precision,
]: ]:
WidgetIO.connect_widget_change_signal(widget, self.set_property) WidgetIO.connect_widget_change_signal(widget, self.set_property)
@@ -122,7 +121,6 @@ class AxisSettings(SettingWidget):
self.ui.y_max, self.ui.y_max,
self.ui.y_log, self.ui.y_log,
self.ui.y_grid, self.ui.y_grid,
self.ui.minimal_crosshair_precision,
]: ]:
property_name = widget.objectName() property_name = widget.objectName()
value = getattr(self.target_widget, property_name) value = getattr(self.target_widget, property_name)
@@ -146,7 +144,6 @@ class AxisSettings(SettingWidget):
self.ui.y_grid, self.ui.y_grid,
self.ui.outer_axes, self.ui.outer_axes,
self.ui.inner_axes, self.ui.inner_axes,
self.ui.minimal_crosshair_precision,
]: ]:
property_name = widget.objectName() property_name = widget.objectName()
value = WidgetIO.get_value(widget) value = WidgetIO.get_value(widget)
@@ -14,6 +14,97 @@
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout"> <layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="2" column="2" colspan="2"> <item row="2" column="2" colspan="2">
<widget class="QGroupBox" name="y_axis_box"> <widget class="QGroupBox" name="y_axis_box">
<property name="title"> <property name="title">
@@ -88,87 +179,6 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="0" column="0" colspan="4"> <item row="0" column="0" colspan="4">
<layout class="QHBoxLayout" name="horizontalLayout"> <layout class="QHBoxLayout" name="horizontalLayout">
<item> <item>
@@ -181,41 +191,8 @@
<item> <item>
<widget class="QLineEdit" name="title"/> <widget class="QLineEdit" name="title"/>
</item> </item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout> </layout>
</item> </item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout> </layout>
</widget> </widget>
<customwidgets> <customwidgets>
@@ -6,84 +6,15 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>250</width> <width>241</width>
<height>612</height> <height>526</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QGridLayout" name="gridLayout">
<item> <item row="4" column="0" colspan="2">
<widget class="QGroupBox" name="general_box">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="4" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="title"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="x_axis_box"> <widget class="QGroupBox" name="x_axis_box">
<property name="title"> <property name="title">
<string>X Axis</string> <string>X Axis</string>
@@ -150,7 +81,28 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item> <item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="title"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<widget class="QGroupBox" name="y_axis_box"> <widget class="QGroupBox" name="y_axis_box">
<property name="title"> <property name="title">
<string>Y Axis</string> <string>Y Axis</string>
@@ -217,6 +169,23 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item row="2" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout> </layout>
</widget> </widget>
<customwidgets> <customwidgets>
@@ -44,7 +44,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
initial_action="drag_mode", initial_action="drag_mode",
tooltip="Mouse Modes", tooltip="Mouse Modes",
checkable=True, checkable=True,
parent=self.target_widget, parent=self,
) )
# Add them to the bundle # Add them to the bundle
@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from qtpy.QtCore import QSize
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QComboBox, QComboBox,
QGroupBox, QGroupBox,
@@ -36,11 +35,7 @@ class CurveSetting(SettingWidget):
self._init_x_box() self._init_x_box()
self._init_y_box() self._init_y_box()
def sizeHint(self) -> QSize: self.setFixedWidth(580) # TODO height is still debate
"""
Returns the size hint for the settings widget.
"""
return QSize(800, 500)
def _init_x_box(self): def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis") self.x_axis_box = QGroupBox("X Axis")
@@ -102,7 +97,7 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box) self.layout.addWidget(self.y_axis_box)
@SafeSlot(popup_error=True) @SafeSlot()
def accept_changes(self): def accept_changes(self):
""" """
Accepts the changes made in the settings widget and applies them to the target widget. Accepts the changes made in the settings widget and applies them to the target widget.
@@ -5,12 +5,13 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import Qt from qtpy.QtGui import QColor
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QColorDialog,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
@@ -21,18 +22,15 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox 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.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 from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -42,6 +40,49 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger 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): class CurveRow(QTreeWidgetItem):
DELETE_BUTTON_COLOR = "#CC181E" DELETE_BUTTON_COLOR = "#CC181E"
"""A unified row that can represent either a device or a DAP curve. """A unified row that can represent either a device or a DAP curve.
@@ -123,40 +164,11 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo.""" """Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device": if self.source == "device":
# Device row: columns 1..2 are device line edits # Device row: columns 1..2 are device line edits
self.device_edit = DeviceComboBox(parent=self.tree) self.device_edit = DeviceLineEdit(parent=self.tree)
self.device_edit.insertItem(0, "") self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
self.device_edit.setEditable(True)
self.entry_edit = SignalComboBox(parent=self.tree)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal: if self.config.signal:
device_index = self.device_edit.findText(self.config.signal.name or "") self.device_edit.setText(self.config.signal.name or "")
if device_index >= 0: self.entry_edit.setText(self.config.signal.entry or "")
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
else:
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
for i in range(self.entry_edit.count()):
entry_data = self.entry_edit.itemData(i)
if entry_data and entry_data.get("obj_name") == self.config.signal.entry:
# If the device name matches an object name, set it
self.entry_edit.setCurrentIndex(i)
break
else:
# If no match found, set the first enabled item
for i in range(self.entry_edit.count()):
model = self.entry_edit.model()
if model.flags(model.index(i, 0)) & Qt.ItemIsEnabled:
self.entry_edit.setCurrentIndex(i)
break
else:
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit) self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit) self.tree.setItemWidget(self, 2, self.entry_edit)
@@ -181,8 +193,8 @@ class CurveRow(QTreeWidgetItem):
def _init_style_controls(self): def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin.""" """Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3 # Color in col 3
self.color_button = ColorButtonNative(color=self.config.color) self.color_button = ColorButton(self.config.color)
self.color_button.color_changed.connect(self._on_color_changed) self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button) self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 4 # Style in col 4
@@ -205,16 +217,20 @@ class CurveRow(QTreeWidgetItem):
self.symbol_spin.setValue(self.config.symbol_size) self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin) self.tree.setItemWidget(self, 6, self.symbol_spin)
@SafeSlot(str, verify_sender=True) def _select_color(self, button):
def _on_color_changed(self, new_color: str):
""" """
Update configuration when the color button emits a change. Selects a new color using a color dialog and applies it to the specified button. Updates
related configuration properties based on the chosen color.
Args: Args:
new_color (str): The new color in hex format. button: The button widget whose color is being modified.
""" """
self.config.color = new_color current_color = QColor(button.color())
self.config.symbol_color = new_color chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
def add_dap_row(self): def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'.""" """Create a new DAP row as a child. Only valid if source='device'."""
@@ -268,11 +284,6 @@ class CurveRow(QTreeWidgetItem):
self.dap_combo.deleteLater() self.dap_combo.deleteLater()
self.dap_combo = None 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 # Remove the item from the tree widget
index = self.tree.indexOfTopLevelItem(self) index = self.tree.indexOfTopLevelItem(self)
if index != -1: if index != -1:
@@ -295,22 +306,13 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry # Gather device name/entry
device_name = "" device_name = ""
device_entry = "" device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"): if hasattr(self, "device_edit"):
device_name = self.device_edit.currentText() device_name = self.device_edit.text()
if hasattr(self, "entry_edit"): if hasattr(self, "entry_edit"):
device_entry = self.entry_edit.currentText() device_entry = self.entry_validator.validate_signal(
index = self.entry_edit.findText(device_entry) name=device_name, entry=self.entry_edit.text()
if index > -1: )
device_entry_info = self.entry_edit.itemData(index) self.entry_edit.setText(device_entry)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry) self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device" self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}" self.config.label = f"{device_name}-{device_entry}"
@@ -335,8 +337,8 @@ class CurveRow(QTreeWidgetItem):
self.config.label = f"{parent_conf.label}-{new_dap}" self.config.label = f"{parent_conf.label}-{new_dap}"
# Common style fields # Common style fields
self.config.color = self.color_button.color self.config.color = self.color_button.color()
self.config.symbol_color = self.color_button.color self.config.symbol_color = self.color_button.color()
self.config.pen_style = self.style_combo.currentText() self.config.pen_style = self.style_combo.currentText()
self.config.pen_width = self.width_spin.value() self.config.pen_width = self.width_spin.value()
self.config.symbol_size = self.symbol_spin.value() self.config.symbol_size = self.symbol_spin.value()
@@ -426,20 +428,13 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget() self.tree = QTreeWidget()
self.tree.setColumnCount(7) self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"]) self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
if idx in (1, 2): # Device name and entry should stretch
header.setSectionResizeMode(idx, QHeaderView.Stretch)
else:
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90) self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70) self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80) self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 50) self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 50) self.tree.setColumnWidth(6, 40)
self.layout.addWidget(self.tree) self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int): def _init_color_buffer(self, size: int):
+40 -262
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Literal from typing import Literal
import lmfit import lmfit
import numpy as np import numpy as np
@@ -9,19 +9,8 @@ import pyqtgraph as pg
from bec_lib import bec_logger, messages from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import ( from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QMainWindow,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
@@ -44,11 +33,6 @@ class WaveformConfig(ConnectionConfig):
color_palette: str | None = Field( color_palette: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True "plasma", description="The color palette of the figure widget.", validate_default=True
) )
max_dataset_size_mb: float = Field(
10,
description="Maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.",
validate_default=True,
)
model_config: dict = {"validate_assignment": True} model_config: dict = {"validate_assignment": True}
_validate_color_palette = field_validator("color_palette")(Colors.validate_color_map) _validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
@@ -102,8 +86,6 @@ class Waveform(PlotBase):
"y_log.setter", "y_log.setter",
"legend_label_size", "legend_label_size",
"legend_label_size.setter", "legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# Waveform Specific RPC Access # Waveform Specific RPC Access
"curves", "curves",
"x_mode", "x_mode",
@@ -112,12 +94,6 @@ class Waveform(PlotBase):
"x_entry.setter", "x_entry.setter",
"color_palette", "color_palette",
"color_palette.setter", "color_palette.setter",
"skip_large_dataset_warning",
"skip_large_dataset_warning.setter",
"skip_large_dataset_check",
"skip_large_dataset_check.setter",
"max_dataset_size_mb",
"max_dataset_size_mb.setter",
"plot", "plot",
"add_dap_curve", "add_dap_curve",
"remove_curve", "remove_curve",
@@ -161,12 +137,12 @@ class Waveform(PlotBase):
# Curve data # Curve data
self._sync_curves = [] self._sync_curves = []
self._async_curves = [] self._async_curves = []
self._async_connected_devices: set[str] = set()
self._slice_index = None self._slice_index = None
self._dap_curves = [] self._dap_curves = []
self._mode = None self._mode: Literal["none", "sync", "async", "mixed"] = "none"
# Scan data # Scan data
self._scan_done = True # means scan is not running
self.old_scan_id = None self.old_scan_id = None
self.scan_id = None self.scan_id = None
self.scan_item = None self.scan_item = None
@@ -186,10 +162,6 @@ class Waveform(PlotBase):
self._init_curve_dialog() self._init_curve_dialog()
self.curve_settings_dialog = None self.curve_settings_dialog = None
# Largedataset guard
self._skip_large_dataset_warning = False # session flag
self._skip_large_dataset_check = False # per-plot flag, to skip the warning for this plot
# Scan status update loop # Scan status update loop
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status()) self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
@@ -330,6 +302,7 @@ class Waveform(PlotBase):
self.curve_settings_dialog = SettingsDialog( self.curve_settings_dialog = SettingsDialog(
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
) )
self.curve_settings_dialog.setFixedWidth(580)
# When the dialog is closed, update the toolbar icon and clear the reference # When the dialog is closed, update the toolbar icon and clear the reference
self.curve_settings_dialog.finished.connect(self._curve_settings_closed) self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
self.curve_settings_dialog.show() self.curve_settings_dialog.show()
@@ -442,8 +415,6 @@ class Waveform(PlotBase):
""" """
Slot for when the axis settings dialog is closed. 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.deleteLater()
self.dap_summary_dialog = None self.dap_summary_dialog = None
self.toolbar.widgets["fit_params"].action.setChecked(False) self.toolbar.widgets["fit_params"].action.setChecked(False)
@@ -574,6 +545,7 @@ class Waveform(PlotBase):
continue continue
config = CurveConfig(**cfg_dict) config = CurveConfig(**cfg_dict)
self._add_curve(config=config) self._add_curve(config=config)
self.update_with_scan_history(-1)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}") logger.error(f"Failed to decode JSON: {e}")
@@ -587,59 +559,6 @@ class Waveform(PlotBase):
""" """
return [item for item in self.plot_item.curves if isinstance(item, Curve)] return [item for item in self.plot_item.curves if isinstance(item, Curve)]
@SafeProperty(bool)
def skip_large_dataset_check(self) -> bool:
"""
Whether to skip the large dataset warning when fetching async data.
"""
return self._skip_large_dataset_check
@skip_large_dataset_check.setter
def skip_large_dataset_check(self, value: bool):
"""
Set whether to skip the large dataset warning when fetching async data.
Args:
value(bool): Whether to skip the large dataset warning.
"""
self._skip_large_dataset_check = value
@SafeProperty(bool)
def skip_large_dataset_warning(self) -> bool:
"""
Whether to skip the large dataset warning when fetching async data.
"""
return self._skip_large_dataset_warning
@skip_large_dataset_warning.setter
def skip_large_dataset_warning(self, value: bool):
"""
Set whether to skip the large dataset warning when fetching async data.
Args:
value(bool): Whether to skip the large dataset warning.
"""
self._skip_large_dataset_warning = value
@SafeProperty(float)
def max_dataset_size_mb(self) -> float:
"""
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
"""
return self.config.max_dataset_size_mb
@max_dataset_size_mb.setter
def max_dataset_size_mb(self, value: float):
"""
Set the maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
Args:
value(float): The maximum dataset size in MB.
"""
if value <= 0:
raise ValueError("Maximum dataset size must be greater than 0.")
self.config.max_dataset_size_mb = value
################################################################################ ################################################################################
# High Level methods for API # High Level methods for API
################################################################################ ################################################################################
@@ -886,6 +805,8 @@ class Waveform(PlotBase):
if config.source == "device": if config.source == "device":
if self.scan_item is None: if self.scan_item is None:
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
if curve in self._async_curves:
self._setup_async_curve(curve)
self.async_signal_update.emit() self.async_signal_update.emit()
self.sync_signal_update.emit() self.sync_signal_update.emit()
if config.source == "dap": if config.source == "dap":
@@ -1093,6 +1014,7 @@ class Waveform(PlotBase):
return return
if current_scan_id != self.scan_id: if current_scan_id != self.scan_id:
self._async_connected_devices.clear()
self.reset() self.reset()
self.new_scan.emit() self.new_scan.emit()
self.new_scan_id.emit(current_scan_id) self.new_scan_id.emit(current_scan_id)
@@ -1133,12 +1055,12 @@ class Waveform(PlotBase):
meta(dict): The message metadata. meta(dict): The message metadata.
""" """
self.sync_signal_update.emit() self.sync_signal_update.emit()
self._scan_done = msg.get("done") status = msg.get("done")
if self._scan_done: if status:
QTimer.singleShot(100, self.update_sync_curves) QTimer.singleShot(100, self.update_sync_curves)
QTimer.singleShot(300, self.update_sync_curves) QTimer.singleShot(300, self.update_sync_curves)
def _fetch_scan_data_and_access(self) -> tuple[dict, str] | tuple[None, None]: def _fetch_scan_data_and_access(self):
""" """
Decide whether the widget is in live or historical mode Decide whether the widget is in live or historical mode
and return the appropriate data dict and access key. and return the appropriate data dict and access key.
@@ -1152,7 +1074,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return None, None return "none", "none"
if hasattr(self.scan_item, "live_data"): if hasattr(self.scan_item, "live_data"):
# Live scan # Live scan
@@ -1168,7 +1090,7 @@ class Waveform(PlotBase):
""" """
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return return "none"
data, access_key = self._fetch_scan_data_and_access() data, access_key = self._fetch_scan_data_and_access()
for curve in self._sync_curves: for curve in self._sync_curves:
device_name = curve.config.signal.name device_name = curve.config.signal.name
@@ -1176,8 +1098,9 @@ class Waveform(PlotBase):
if access_key == "val": if access_key == "val":
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None) device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else: else:
entry_obj = data.get(device_name, {}).get(device_entry) device_data = (
device_data = entry_obj.read()["value"] if entry_obj else None data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
)
x_data = self._get_x_data(device_name, device_entry) x_data = self._get_x_data(device_name, device_entry)
if x_data is not None: if x_data is not None:
if len(x_data) == 1: if len(x_data) == 1:
@@ -1211,12 +1134,9 @@ class Waveform(PlotBase):
if access_key == "val": # live access if access_key == "val": # live access
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None) device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else: # history access else: # history access
dataset_obj = data.get(device_name, {}) device_data = (
if self._skip_large_dataset_check is False: data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry): )
continue # user declined to load; skip this curve
entry_obj = dataset_obj.get(device_entry, None)
device_data = entry_obj.read()["value"] if entry_obj else None
# if shape is 2D cast it into 1D and take the last waveform # if shape is 2D cast it into 1D and take the last waveform
if len(np.shape(device_data)) > 1: if len(np.shape(device_data)) > 1:
@@ -1245,23 +1165,6 @@ class Waveform(PlotBase):
self.request_dap_update.emit() self.request_dap_update.emit()
def _check_async_signal_found(self, name: str, signal: str) -> bool:
"""
Check if the async signal is found in the BEC device manager.
Args:
name(str): The name of the async signal.
signal(str): The entry of the async signal.
Returns:
bool: True if the async signal is found, False otherwise.
"""
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
for entry_name, _, entry_data in bec_async_signals:
if entry_name == name and entry_data.get("obj_name") == signal:
return True
return False
def _setup_async_curve(self, curve: Curve): def _setup_async_curve(self, curve: Curve):
""" """
Setup async curve. Setup async curve.
@@ -1270,46 +1173,30 @@ class Waveform(PlotBase):
curve(Curve): The curve to set up. curve(Curve): The curve to set up.
""" """
name = curve.config.signal.name name = curve.config.signal.name
signal = curve.config.signal.entry self.bec_dispatcher.disconnect_slot(
async_signal_found = self._check_async_signal_found(name, signal) self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
)
try: try:
curve.clear_data() curve.clear_data()
except KeyError: except KeyError:
logger.warning(f"Curve {name} not found in plot item.") logger.warning(f"Curve {name} not found in plot item.")
pass pass
# New endpoint for async signals # Connect only once per device signal
if async_signal_found: if name not in self._async_connected_devices:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_signal(self.old_scan_id, name, signal),
)
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_signal(self.scan_id, name, signal),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
# old endpoint
else:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.old_scan_id, name),
)
self.bec_dispatcher.connect_slot( self.bec_dispatcher.connect_slot(
self.on_async_readback, self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name), MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True, from_start=True,
cb_info={"scan_id": self.scan_id}, cb_info={"scan_id": self.scan_id},
) )
logger.info(f"Setup async curve {name}") self._async_connected_devices.add(name)
logger.info(f"Async read-back connected for {name}")
@SafeSlot(dict, dict, verify_sender=True) @SafeSlot(dict, dict, verify_sender=True)
def on_async_readback(self, msg, metadata): def on_async_readback(self, msg, metadata):
""" """
Get async data readback. This code needs to be fast, therefor we try Get async data readback. This code needs to be fast; therefore, we try
to reduce the number of copies in between cycles. Be careful when refactoring to reduce the number of copies in between cycles. Be careful when refactoring
this part as it will affect the performance of the async readback. this part as it will affect the performance of the async readback.
@@ -1585,35 +1472,29 @@ class Waveform(PlotBase):
if access_key == "val": # live data if access_key == "val": # live data
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0]) x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
else: # history data else: # history data
entry_obj = data.get(x_name, {}).get(x_entry) x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
x_data = entry_obj.read()["value"] if entry_obj else [0] new_suffix = f" [custom: {x_name}-{x_entry}]"
new_suffix = f" (custom: {x_name}-{x_entry})"
# 2 User wants timestamp # 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp": if self.x_axis_mode["name"] == "timestamp":
if access_key == "val": # live if access_key == "val": # live
x_data = data.get(device_name, {}).get(device_entry, None) timestamps = data[device_name][device_entry].timestamps
if x_data is None:
return None
else:
timestamps = x_data.timestamps
else: # history data else: # history data
entry_obj = data.get(device_name, {}).get(device_entry) timestamps = data[device_name][device_entry].read().get("timestamp", [0])
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
x_data = timestamps x_data = timestamps
new_suffix = " (timestamp)" new_suffix = " [timestamp]"
# 3 User wants index # 3 User wants index
if self.x_axis_mode["name"] == "index": if self.x_axis_mode["name"] == "index":
x_data = None x_data = None
new_suffix = " (index)" new_suffix = " [index]"
# 4 Best effort automatic mode # 4 Best effort automatic mode
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto": if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
# 4.1 If there are async curves, use index # 4.1 If there are async curves, use index
if len(self._async_curves) > 0: if len(self._async_curves) > 0:
x_data = None x_data = None
new_suffix = " (auto: index)" new_suffix = " [auto: index]"
# 4.2 If there are sync curves, use the first device from the scan report # 4.2 If there are sync curves, use the first device from the scan report
else: else:
try: try:
@@ -1626,9 +1507,8 @@ class Waveform(PlotBase):
if access_key == "val": if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None) x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
else: else:
entry_obj = data.get(x_name, {}).get(x_entry) x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
x_data = entry_obj.read()["value"] if entry_obj else None new_suffix = f" [auto: {x_name}-{x_entry}]"
new_suffix = f" (auto: {x_name}-{x_entry})"
self._update_x_label_suffix(new_suffix) self._update_x_label_suffix(new_suffix)
return x_data return x_data
@@ -1680,7 +1560,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return None return "none"
if hasattr(self.scan_item, "live_data"): if hasattr(self.scan_item, "live_data"):
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
@@ -1704,8 +1584,6 @@ class Waveform(PlotBase):
dev_name = curve.config.signal.name dev_name = curve.config.signal.name
if dev_name in readout_priority_async: if dev_name in readout_priority_async:
self._async_curves.append(curve) self._async_curves.append(curve)
if hasattr(self.scan_item, "live_data"):
self._setup_async_curve(curve)
found_async = True found_async = True
elif dev_name in readout_priority_sync: elif dev_name in readout_priority_sync:
self._sync_curves.append(curve) self._sync_curves.append(curve)
@@ -1782,106 +1660,6 @@ class Waveform(PlotBase):
################################################################################ ################################################################################
# Utility Methods # Utility Methods
################################################################################ ################################################################################
# Large dataset handling helpers
def _check_dataset_size_and_confirm(self, dataset_obj, device_entry: str) -> bool:
"""
Check the size of the dataset and confirm with the user if it exceeds the limit.
Args:
dataset_obj: The dataset object containing the information.
device_entry( str): The specific device entry to check.
Returns:
bool: True if the dataset is within the size limit or user confirmed to load it,
False if the dataset exceeds the size limit and user declined to load it.
"""
try:
info = dataset_obj._info
mem_bytes = info.get(device_entry, {}).get("value", {}).get("mem_size", 0)
# Fallback grab first entry if lookup failed
if mem_bytes == 0 and info:
first_key = next(iter(info))
mem_bytes = info[first_key]["value"]["mem_size"]
size_mb = mem_bytes / (1024 * 1024)
print(f"Dataset size: {size_mb:.1f} MB")
except Exception as exc: # noqa: BLE001
logger.error(f"Unable to evaluate dataset size: {exc}")
return True
if size_mb <= self.config.max_dataset_size_mb:
return True
logger.warning(
f"Attempt to load large dataset: {size_mb:.1f} MB "
f"(limit {self.config.max_dataset_size_mb} MB)"
)
if self._skip_large_dataset_warning:
logger.info("Skipping large dataset warning dialog.")
return False
return self._confirm_large_dataset(size_mb)
def _confirm_large_dataset(self, size_mb: float) -> bool:
"""
Confirm with the user whether to load a large dataset with dialog popup.
Also allows the user to adjust the maximum dataset size limit and if user
wants to see this popup again during session.
Args:
size_mb(float): Size of the dataset in MB.
Returns:
bool: True if the user confirmed to load the dataset, False otherwise.
"""
if self._skip_large_dataset_warning:
return True
dialog = QDialog(self)
dialog.setWindowTitle("Large dataset detected")
main_dialog_layout = QVBoxLayout(dialog)
# Limit adjustment widgets
limit_adjustment_layout = QHBoxLayout()
limit_adjustment_layout.addWidget(QLabel("New limit (MB):"))
spin = QDoubleSpinBox()
spin.setRange(0.001, 4096)
spin.setDecimals(3)
spin.setSingleStep(0.01)
spin.setValue(self.config.max_dataset_size_mb)
spin.valueChanged.connect(lambda value: setattr(self.config, "max_dataset_size_mb", value))
limit_adjustment_layout.addWidget(spin)
# Don't show again checkbox
checkbox = QCheckBox("Don't show this again for this session")
buttons = QDialogButtonBox(
QDialogButtonBox.Yes | QDialogButtonBox.No, Qt.Horizontal, dialog
)
buttons.accepted.connect(dialog.accept) # Yes
buttons.rejected.connect(dialog.reject) # No
# widget layout
main_dialog_layout.addWidget(
QLabel(
f"The selected dataset is {size_mb:.1f} MB which exceeds the "
f"current limit of {self.config.max_dataset_size_mb} MB.\n"
)
)
main_dialog_layout.addLayout(limit_adjustment_layout)
main_dialog_layout.addWidget(checkbox)
main_dialog_layout.addWidget(QLabel("Would you like to display dataset anyway?"))
main_dialog_layout.addWidget(buttons)
result = dialog.exec() # modal; waits for user choice
# Respect the “don't show again” checkbox for *either* choice
if checkbox.isChecked():
self._skip_large_dataset_warning = True
if result == QDialog.Accepted:
self.config.max_dataset_size_mb = spin.value()
return True
return False
def _ensure_str_list(self, entries: list | tuple | np.ndarray): def _ensure_str_list(self, entries: list | tuple | np.ndarray):
""" """
Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either) Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
@@ -2012,7 +1790,7 @@ class DemoApp(QMainWindow): # pragma: no cover
self.setCentralWidget(self.main_widget) self.setCentralWidget(self.main_widget)
self.waveform_popup = Waveform(popups=True) self.waveform_popup = Waveform(popups=True)
self.waveform_popup.plot(y_name="waveform") self.waveform_popup.plot(y_name="monitor_async")
self.waveform_side = Waveform(popups=False) self.waveform_side = Waveform(popups=False)
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel") self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
@@ -1,46 +1,12 @@
import sys import sys
from enum import Enum
from string import Template from string import Template
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
from qtpy.QtGui import QColor, QPainter, QPainterPath from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class BECProgressBar(BECWidget, QWidget): class BECProgressBar(BECWidget, QWidget):
@@ -55,8 +21,6 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum", "set_minimum",
"label_template", "label_template",
"label_template.setter", "label_template.setter",
"state",
"state.setter",
"_get_label", "_get_label",
] ]
ICON_NAME = "page_control" ICON_NAME = "page_control"
@@ -84,38 +48,27 @@ class BECProgressBar(BECWidget, QWidget):
self._completed_color = accent_colors.success self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50) self._border_color = QColor(50, 50, 50)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = dict(PROGRESS_STATE_COLORS)
# layout settings # layout settings
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value") self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200) self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic) self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar # label on top of the progress bar
self.center_label = QLabel(self) self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignHCenter) self.center_label.setAlignment(Qt.AlignCenter)
self.center_label.setStyleSheet("color: white;") self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0) self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(10, 0, 10, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
layout.addWidget(self.center_label) layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout) self.setLayout(layout)
self.update() self.update()
self._adjust_label_width()
@SafeProperty( @Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
)
def label_template(self): def label_template(self):
""" """
The template for the center label. Use $value, $maximum, and $percentage to insert the values. The template for the center label. Use $value, $maximum, and $percentage to insert the values.
@@ -130,11 +83,10 @@ class BECProgressBar(BECWidget, QWidget):
@label_template.setter @label_template.setter
def label_template(self, template): def label_template(self, template):
self._label_template = template self._label_template = template
self._adjust_label_width()
self.set_value(self._user_value) self.set_value(self._user_value)
self.update() self.update()
@SafeProperty(float, designable=False) @Property(float, designable=False)
def _progressbar_value(self): def _progressbar_value(self):
""" """
The current value of the progress bar. The current value of the progress bar.
@@ -154,20 +106,8 @@ class BECProgressBar(BECWidget, QWidget):
percentage=int((self.map_value(self._user_value) / self._maximum) * 100), percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
) )
def _adjust_label_width(self): @Slot(float)
""" @Slot(int)
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value): def set_value(self, value):
""" """
Set the value of the progress bar. Set the value of the progress bar.
@@ -182,88 +122,35 @@ class BECProgressBar(BECWidget, QWidget):
self._target_value = self.map_value(value) self._target_value = self.map_value(value)
self._user_value = value self._user_value = value
self.center_label.setText(self._update_template()) self.center_label.setText(self._update_template())
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self.animate_progress() self.animate_progress()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
return self._state
@state.setter
def state(self, state):
"""
Set the visual state of the progress bar.
Args:
state(ProgressState | str): The state to set. Can be one of the
"""
if isinstance(state, str):
state = ProgressState(state.lower())
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self.update()
@SafeProperty(float, doc="Base corner radius in pixels (autoscaled down on small bars).")
def corner_radius(self) -> float:
return self._corner_radius
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self.update()
@SafeProperty(float)
def padding_left_right(self) -> float:
return self._padding_left_right
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1) rect = self.rect().adjusted(10, 0, -10, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background # Draw background
painter.setBrush(self._background_color) painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, radius, radius) # Rounded corners painter.drawRoundedRect(rect, 10, 10) # Rounded corners
# Draw border # Draw border
painter.setBrush(Qt.NoBrush) painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color) painter.setPen(self._border_color)
painter.drawRoundedRect(rect, radius, radius) painter.drawRoundedRect(rect, 10, 10)
# Determine progress colour based on current state # Determine progress color based on completion
if self._state == ProgressState.PAUSED: if self._value >= self._maximum:
current_color = self._state_colors[ProgressState.PAUSED] current_color = self._completed_color
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else: else:
current_color = self._state_colors[ProgressState.NORMAL] current_color = self._progress_color
# Set clipping region to preserve the background's rounded corners # Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted( progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0 0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
) )
clip_path = QPainterPath() clip_path = QPainterPath()
clip_path.addRoundedRect( clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path) painter.setClipPath(clip_path)
# Draw progress bar # Draw progress bar
@@ -281,7 +168,7 @@ class BECProgressBar(BECWidget, QWidget):
self._value_animation.setEndValue(self._target_value) self._value_animation.setEndValue(self._target_value)
self._value_animation.start() self._value_animation.start()
@SafeProperty(float) @Property(float)
def maximum(self): def maximum(self):
""" """
The maximum value of the progress bar. The maximum value of the progress bar.
@@ -295,7 +182,7 @@ class BECProgressBar(BECWidget, QWidget):
""" """
self.set_maximum(maximum) self.set_maximum(maximum)
@SafeProperty(float) @Property(float)
def minimum(self): def minimum(self):
""" """
The minimum value of the progress bar. The minimum value of the progress bar.
@@ -306,7 +193,7 @@ class BECProgressBar(BECWidget, QWidget):
def minimum(self, minimum: float): def minimum(self, minimum: float):
self.set_minimum(minimum) self.set_minimum(minimum)
@SafeProperty(float) @Property(float)
def initial_value(self): def initial_value(self):
""" """
The initial value of the progress bar. The initial value of the progress bar.
@@ -317,7 +204,7 @@ class BECProgressBar(BECWidget, QWidget):
def initial_value(self, value: float): def initial_value(self, value: float):
self.set_value(value) self.set_value(value)
@SafeSlot(float) @Slot(float)
def set_maximum(self, maximum: float): def set_maximum(self, maximum: float):
""" """
Set the maximum value of the progress bar. Set the maximum value of the progress bar.
@@ -326,11 +213,10 @@ class BECProgressBar(BECWidget, QWidget):
maximum (float): The maximum value. maximum (float): The maximum value.
""" """
self._user_maximum = maximum self._user_maximum = maximum
self._adjust_label_width()
self.set_value(self._user_value) # Update the value to fit the new range self.set_value(self._user_value) # Update the value to fit the new range
self.update() self.update()
@SafeSlot(float) @Slot(float)
def set_minimum(self, minimum: float): def set_minimum(self, minimum: float):
""" """
Set the minimum value of the progress bar. Set the minimum value of the progress bar.
@@ -1,17 +0,0 @@
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.progress.scan_progressbar.scan_progress_bar_plugin import (
ScanProgressBarPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanProgressBarPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -1 +0,0 @@
{'files': ['scan_progressbar.py']}
@@ -1,54 +0,0 @@
# 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.progress.scan_progressbar.scan_progressbar import ScanProgressBar
DOM_XML = """
<ui language='c++'>
<widget class='ScanProgressBar' name='scan_progress_bar'>
</widget>
</ui>
"""
class ScanProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ScanProgressBar(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(ScanProgressBar.ICON_NAME)
def includeFile(self):
return "scan_progress_bar"
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 "ScanProgressBar"
def toolTip(self):
return "A progress bar that is hooked up to the scan progress of a scan."
def whatsThis(self):
return self.toolTip()
@@ -1,320 +0,0 @@
from __future__ import annotations
import enum
import os
import time
from typing import Literal
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
logger = bec_logger.logger
class ProgressSource(enum.Enum):
"""
Enum to define the source of the progress.
"""
SCAN_PROGRESS = "scan_progress"
DEVICE_PROGRESS = "device_progress"
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
super().__init__(parent=parent)
self.start_time = time.time()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(100) # update the elapsed time every 100 ms
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every 100 ms by a QTimer.
"""
self._elapsed_time += 0.1
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
# format the elapsed time to a string in the format HH:MM:SS
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
def _format_time(self, seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class ScanProgressBar(BECWidget, QWidget):
"""
Widget to display a progress bar that is hooked up to the scan progress of a scan.
If you want to manually set the progress, it is recommended to use the BECProgressbar or QProgressbar directly.
"""
ICON_NAME = "timelapse"
progress_started = Signal()
progress_finished = Signal()
def __init__(self, parent=None, client=None, config=None, gui_id=None, one_line_design=False):
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
ui_file = os.path.join(
os.path.dirname(__file__),
"scan_progressbar_one_line.ui" if one_line_design else "scan_progressbar.ui",
)
self.ui = UILoader(self).loader(ui_file)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.ui)
self.setLayout(self.layout)
self.progressbar = self.ui.progressbar
self.connect_to_queue()
self._progress_source = None
self.task = None
self.scan_number = None
self.progress_started.connect(lambda: print("Scan progress started"))
def connect_to_queue(self):
"""
Connect to the queue status signal.
"""
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
def set_progress_source(self, source: ProgressSource, device=None):
"""
Set the source of the progress.
"""
if self._progress_source == source:
self.update_source_label(source, device=device)
return
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self._progress_source = source
self.bec_dispatcher.connect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self.update_source_label(source, device=device)
# self.progress_started.emit()
def update_source_label(self, source: ProgressSource, device=None):
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
logger.info(f"Set progress source to {text}")
self.ui.source_label.setText(text)
@SafeSlot(dict, dict)
def on_progress_update(self, msg_content: dict, metadata: dict):
"""
Update the progress bar based on the progress message.
"""
value = msg_content["value"]
max_value = msg_content.get("max_value", 100)
done = msg_content.get("done", False)
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
"status", "open"
)
if self.task is None:
return
self.task.update(value, max_value, done)
self.update_labels()
self.progressbar.set_maximum(self.task.max_value)
self.progressbar.state = ProgressState.from_bec_status(status)
self.progressbar.set_value(self.task.value)
if done:
self.task = None
self.progress_finished.emit()
return
@SafeProperty(bool)
def show_elapsed_time(self):
return self.ui.elapsed_time_label.isVisible()
@show_elapsed_time.setter
def show_elapsed_time(self, value):
self.ui.elapsed_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_remaining_time(self):
return self.ui.remaining_time_label.isVisible()
@show_remaining_time.setter
def show_remaining_time(self, value):
self.ui.remaining_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_source_label(self):
return self.ui.source_label.isVisible()
@show_source_label.setter
def show_source_label(self, value):
self.ui.source_label.setVisible(value)
def update_labels(self):
"""
Update the labels based on the progress task.
"""
if self.task is None:
return
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
self.ui.remaining_time_label.setText(self.task.time_remaining)
@SafeSlot(dict, dict, verify_sender=True)
def on_queue_update(self, msg_content, metadata):
"""
Update the progress bar based on the queue status.
"""
if not "queue" in msg_content:
return
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
if len(primary_queue_info) == 0:
return
scan_info = primary_queue_info[0]
if scan_info is None:
return
if scan_info.get("status").lower() == "running" and self.task is None:
self.task = ProgressTask(parent=self)
self.progress_started.emit()
active_request_block = scan_info.get("active_request_block", {})
if active_request_block is None:
return
self.scan_number = active_request_block.get("scan_number")
report_instructions = active_request_block.get("report_instructions", [])
if not report_instructions:
return
# for now, let's just use the first instruction
instruction = report_instructions[0]
if "scan_progress" in instruction:
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
elif "device_progress" in instruction:
device = instruction["device_progress"][0]
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
def cleanup(self):
if self.task is not None:
self.task.timer.stop()
self.close()
self.deleteLater()
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_source.value)
),
)
self.progressbar.close()
self.progressbar.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
bec_logger.disabled_modules = ["bec_lib"]
app = QApplication([])
widget = ScanProgressBar()
widget.show()
app.exec_()
@@ -1,141 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>211</width>
<height>60</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>60</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>1</number>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="source_layout">
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<spacer name="source_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="padding_left_right" stdset="0">
<double>2.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="timer_layout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<spacer name="timer_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
@@ -1,124 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>328</width>
<height>24</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>24</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="minimumSize">
<size>
<width>30</width>
<height>0</height>
</size>
</property>
<property name="padding_left_right" stdset="0">
<double>5.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="dash">
<property name="text">
<string>-</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

Some files were not shown because too many files have changed in this diff Show More