mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-12 11:40:54 +02:00
Compare commits
2 Commits
feature/sh
...
feat/dap_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc246cf92 | ||
|
|
550753078b |
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
65
.github/actions/bw_install/action.yml
vendored
65
.github/actions/bw_install/action.yml
vendored
@@ -1,65 +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
|
||||
sudo apt-get -y install ttyd
|
||||
|
||||
- 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]
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
@@ -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
.github/workflows/check_pr.yml
vendored
28
.github/workflows/check_pr.yml
vendored
@@ -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"]
|
||||
}
|
||||
64
.github/workflows/child_repos.yml
vendored
64
.github/workflows/child_repos.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch for BEC Core'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch for Ophyd Devices'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch for BEC Widgets'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
|
||||
jobs:
|
||||
bec:
|
||||
name: BEC Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Install BEC and dependencies
|
||||
uses: ./.github/actions/bec_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/bec_e2e_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
80
.github/workflows/ci.yml
vendored
80
.github/workflows/ci.yml
vendored
@@ -1,80 +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
|
||||
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
58
.github/workflows/end2end-conda.yml
vendored
58
.github/workflows/end2end-conda.yml
vendored
@@ -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
.github/workflows/formatter.yml
vendored
66
.github/workflows/formatter.yml
vendored
@@ -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
.github/workflows/generate-cli-check.yml
vendored
49
.github/workflows/generate-cli-check.yml
vendored
@@ -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
.github/workflows/pytest-matrix.yml
vendored
59
.github/workflows/pytest-matrix.yml
vendored
@@ -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.11", "3.12", "3.13"]
|
||||
|
||||
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 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
72
.github/workflows/pytest.yml
vendored
72
.github/workflows/pytest.yml
vendored
@@ -1,72 +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 test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: image-references
|
||||
path: bec_widgets/tests/reference_failures/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: bec-project/bec_widgets
|
||||
103
.github/workflows/semantic_release.yml
vendored
103
.github/workflows/semantic_release.yml
vendored
@@ -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
|
||||
19
.github/workflows/stale-issues.yml
vendored
19
.github/workflows/stale-issues.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '00 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
days-before-stale: 120
|
||||
days-before-close: 14
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
40
.github/workflows/sync-issues-pr.yml
vendored
@@ -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
.gitignore
vendored
3
.gitignore
vendored
@@ -64,9 +64,6 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
275
.gitlab-ci.yml
Normal file
275
.gitlab-ci.yml
Normal file
@@ -0,0 +1,275 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH:
|
||||
description: bec branch
|
||||
value: main
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: ophyd_devices branch
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v,--random-order,tests/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
|
||||
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
|
||||
fi
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install bec_lib[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
# Fail the job if the pylint score is below 9
|
||||
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
needs: ["tests"]
|
||||
script:
|
||||
- git config --global user.name "ci_update_bot"
|
||||
- git config --global user.email "ci_update_bot@bec.ch"
|
||||
- git checkout "$CI_COMMIT_REF_NAME"
|
||||
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
|
||||
|
||||
# delete all local tags
|
||||
- git tag -l | xargs git tag -d
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
pages:
|
||||
stage: Deploy
|
||||
needs: ["semver"]
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
17
.gitlab/issue_templates/bug_report_template.md
Normal file
17
.gitlab/issue_templates/bug_report_template.md
Normal file
@@ -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
|
||||
|
||||
[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
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
@@ -47,3 +37,4 @@ assignees: ''
|
||||
## 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]
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
## 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
|
||||
|
||||
[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
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## How to test
|
||||
|
||||
- Run unit tests
|
||||
- Open [widget] in designer and play around with the properties
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this PR.]
|
||||
[Describe any potential side effects or risks of merging this MR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.11
|
||||
py-version=3.10
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
@@ -7,13 +7,13 @@ version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
python: "3.10"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
configuration: docs/conf.py
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
# formats:
|
||||
@@ -21,7 +21,5 @@ sphinx:
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .[dev]
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
|
||||
5317
CHANGELOG.md
5317
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
Copyright (c) 2023, bec
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
220
README.md
220
README.md
@@ -1,200 +1,70 @@
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
## Highlights
|
||||
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Features](#features)
|
||||
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
|
||||
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
|
||||
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
|
||||
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
|
||||
- [Widget Library](#widget-library)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
|
||||
## Installation
|
||||
|
||||
Use any of the following setups:
|
||||
|
||||
### Stable release
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
|
||||
### From source (recommended for development)
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bec-project/bec_widgets.git
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev]
|
||||
pip install -e .[dev,pyside6]
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

|
||||
|
||||
### 2. Qt Designer plugins + BEC Launcher (no glue)
|
||||
|
||||
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
|
||||
visually in Designer, save a `.ui`, then launch it with
|
||||
the **BEC Launcher**—no glue code. Widgets auto‑connect to BEC/Redis on startup, so your UI is operational immediately.
|
||||
|
||||

|
||||
|
||||
### 3. Robust RPC from CLI & remote scripting
|
||||
|
||||
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
|
||||
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
|
||||
active devices, topics)—so your UI can be heavily automated.
|
||||
|
||||
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
|
||||
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
|
||||
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
|
||||
actions,switch profiles, open diagnostic views, or start specific scans.
|
||||
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
|
||||
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
|
||||
|
||||

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
199
bec_widgets/applications/alignment/alignment_1d/alignment_1d.py
Normal file
199
bec_widgets/applications/alignment/alignment_1d/alignment_1d.py
Normal file
@@ -0,0 +1,199 @@
|
||||
""" This module contains the GUI for the 1D alignment application.
|
||||
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
|
||||
PositionerGroup,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
|
||||
"""Initialization
|
||||
|
||||
Args:
|
||||
config: Configuration of the application.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
"""
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
QApplication.instance().aboutToQuit.connect(self.close)
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.ui_file = "alignment_1d.ui"
|
||||
self.ui = None
|
||||
self.progress_bar = None
|
||||
self.waveform = None
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialise the UI from QT Designer file"""
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
|
||||
# Customize the plotting widget
|
||||
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
|
||||
self._customise_bec_waveform_widget()
|
||||
# Setup comboboxes for motor and signal selection
|
||||
# FIXME after changing the filtering in the combobox
|
||||
self._setup_signal_combobox()
|
||||
# Setup motor indicator
|
||||
self._setup_motor_indicator()
|
||||
# Setup progress bar
|
||||
self._setup_progress_bar()
|
||||
# Add actions buttons
|
||||
self._customise_buttons()
|
||||
# Hook scaninfo updates
|
||||
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
|
||||
|
||||
def show(self):
|
||||
return self.ui.show()
|
||||
|
||||
##############################
|
||||
############ SLOTS ###########
|
||||
##############################
|
||||
|
||||
@Slot(dict, dict)
|
||||
def scan_status_callback(self, content: dict, _) -> None:
|
||||
"""This slot allows to enable/disable the UI critical components when a scan is running"""
|
||||
if content["status"] in ["open"]:
|
||||
self.enable_ui(False)
|
||||
elif content["status"] in ["aborted", "halted", "closed"]:
|
||||
self.enable_ui(True)
|
||||
|
||||
@Slot(tuple)
|
||||
def move_to_center(self, move_request: tuple) -> None:
|
||||
"""Move the selected motor to the center"""
|
||||
motor = self.ui.device_combobox.currentText()
|
||||
if move_request[0] in ["center", "center1", "center2"]:
|
||||
pos = move_request[1]
|
||||
self.dev.get(motor).move(float(pos), relative=False)
|
||||
|
||||
@Slot()
|
||||
def reset_progress_bar(self) -> None:
|
||||
"""Reset the progress bar"""
|
||||
self.progress_bar.set_value(0)
|
||||
self.progress_bar.set_minimum(0)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_progress_bar(self, content: dict, _) -> None:
|
||||
"""Hook to update the progress bar
|
||||
|
||||
Args:
|
||||
content: Content of the scan progress message.
|
||||
metadata: Metadata of the message.
|
||||
"""
|
||||
if content["max_value"] == 0:
|
||||
self.progress_bar.set_value(0)
|
||||
return
|
||||
self.progress_bar.set_maximum(content["max_value"])
|
||||
self.progress_bar.set_value(content["value"])
|
||||
|
||||
@Slot()
|
||||
def clear_queue(self) -> None:
|
||||
"""Clear the scan queue"""
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
##############################
|
||||
######## END OF SLOTS ########
|
||||
##############################
|
||||
|
||||
def enable_ui(self, enable: bool) -> None:
|
||||
"""Enable or disable the UI components"""
|
||||
# Enable/disable motor and signal selection
|
||||
self.ui.device_combobox_2.setEnabled(enable)
|
||||
# Enable/disable DAP selection
|
||||
self.ui.dap_combo_box.setEnabled(enable)
|
||||
# Enable/disable Scan Button
|
||||
# self.ui.scan_button.setEnabled(enable)
|
||||
# Disable move to buttons in LMFitDialog
|
||||
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
|
||||
|
||||
def _customise_buttons(self) -> None:
|
||||
"""Add action buttons for the Action Control.
|
||||
In addition, we are adding a callback to also clear the queue to the stop button
|
||||
to ensure that upon clicking the button, no scans from another client may be queued
|
||||
which would be confusing without the queue widget.
|
||||
"""
|
||||
fit_dialog = self.ui.findChild(LMFitDialog)
|
||||
fit_dialog.active_action_list = ["center", "center1", "center2"]
|
||||
fit_dialog.move_action.connect(self.move_to_center)
|
||||
stop_button = self.ui.findChild(StopButton)
|
||||
stop_button.button.setText("Stop and Clear Queue")
|
||||
stop_button.button.clicked.connect(self.clear_queue)
|
||||
|
||||
def _customise_bec_waveform_widget(self) -> None:
|
||||
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
|
||||
self.waveform.toolbar.clear()
|
||||
|
||||
def _setup_motor_indicator(self) -> None:
|
||||
"""Setup the arrow item"""
|
||||
self.waveform.waveform.tick_item.add_to_plot()
|
||||
positioner_box = self.ui.findChild(PositionerGroup)
|
||||
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
|
||||
self.waveform.waveform.tick_item.set_position(0)
|
||||
|
||||
def _setup_signal_combobox(self) -> None:
|
||||
"""Setup signal selection"""
|
||||
# FIXME after changing the filtering in the combobox
|
||||
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
|
||||
self.ui.device_combobox_2.setCurrentText(signals[0])
|
||||
self.ui.device_combobox_2.set_device_filter("Signal")
|
||||
|
||||
def _setup_progress_bar(self) -> None:
|
||||
"""Setup progress bar"""
|
||||
# FIXME once the BECScanProgressBar is implemented
|
||||
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
|
||||
self.progress_bar.set_value(0)
|
||||
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
|
||||
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
|
||||
|
||||
def close(self):
|
||||
logger.info("Disconnecting", repr(self.bec_dispatcher))
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
logger.info("Shutting down BEC Client", repr(self.client))
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
window = Alignment1D()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
615
bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui
Normal file
615
bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui
Normal file
@@ -0,0 +1,615 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>mainWindow</class>
|
||||
<widget class="QMainWindow" name="mainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1611</width>
|
||||
<height>1019</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Alignment tool</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="widget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="DarkModeButton" name="dark_mode_button"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="label" stdset="0">
|
||||
<string>BEC Servers</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>SLS Light On</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEAMLINE Checks</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="StopButton" name="stop_button">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="bec_progress_bar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ControlTab">
|
||||
<attribute name="title">
|
||||
<string>Alignment Control</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_4" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control">
|
||||
<property name="current_scan" stdset="0">
|
||||
<string>line_scan</string>
|
||||
</property>
|
||||
<property name="hide_arg_box" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="hide_scan_selection_combobox" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_add_remove_buttons" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="PositionerGroup" name="positioner_group"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_3" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>4</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_2" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Monitor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceComboBox" name="device_combobox_2"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>LMFit Model</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DapComboBox" name="dap_combo_box"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Enable ROI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="toggle_switch">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>3</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Activate linear region select for LMFit</string>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECWaveformWidget" name="bec_waveform_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<height>450</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="clear_curves_on_plot_update" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="LMFitDialog" name="lm_fit_dialog">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>190</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="always_show_latest" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_curve_selection" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_summary" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Logbook</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<property name="leftMargin">
|
||||
<number>2</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="WebsiteWidget" name="website_widget">
|
||||
<property name="url" stdset="0">
|
||||
<string>https://scilog.psi.ch/login</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DapComboBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dap_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DarkModeButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dark_mode_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionerGroup</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>positioner_group</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECWaveformWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_waveform_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LMFitDialog</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>lm_fit_dialog</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>toggle_switch</sender>
|
||||
<signal>enabled(bool)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>toogle_roi_select(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1042</x>
|
||||
<y>212</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>322</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>bec_waveform_widget</sender>
|
||||
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
|
||||
<receiver>lm_fit_dialog</receiver>
|
||||
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1099</x>
|
||||
<y>258</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1157</x>
|
||||
<y>929</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>plot(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>427</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_y_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>dap_combo_box</sender>
|
||||
<signal>new_dap_config(QString,QString,QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>add_dap(QString,QString,QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>447</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>positioner_group</receiver>
|
||||
<slot>set_positioners(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>230</x>
|
||||
<y>306</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>187</x>
|
||||
<y>926</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>set_x(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>972</x>
|
||||
<y>509</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_x_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>794</x>
|
||||
<y>202</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
84
bec_widgets/applications/bec_app.py
Normal file
84
bec_widgets/applications/bec_app.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Launcher for BEC GUI Applications
|
||||
|
||||
Application must be located in bec_widgets/applications ;
|
||||
in order for the launcher to find the application, it has to be put in
|
||||
a subdirectory with the same name as the main Python module:
|
||||
|
||||
/bec_widgets/applications
|
||||
├── alignment
|
||||
│ └── alignment_1d
|
||||
│ └── alignment_1d.py
|
||||
├── other_app
|
||||
└── other_app.py
|
||||
|
||||
The tree above would contain 2 applications, alignment_1d and other_app.
|
||||
|
||||
The Python module for the application must have `if __name__ == "__main__":`
|
||||
in order for the launcher to execute it (it is run with `python -m`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODULE_PATH = os.path.dirname(__file__)
|
||||
|
||||
|
||||
def find_apps(base_dir: str) -> list[str]:
|
||||
matching_modules = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
parent_dir = os.path.basename(root)
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py") and file != "__init__.py":
|
||||
file_name_without_ext = os.path.splitext(file)[0]
|
||||
|
||||
if file_name_without_ext == parent_dir:
|
||||
rel_path = os.path.relpath(root, base_dir)
|
||||
module_path = rel_path.replace(os.sep, ".")
|
||||
|
||||
module_name = f"{module_path}.{file_name_without_ext}"
|
||||
matching_modules.append((file_name_without_ext, module_name))
|
||||
|
||||
return matching_modules
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BEC application launcher")
|
||||
|
||||
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
|
||||
|
||||
# Add a positional argument for the module, which acts as a fallback if -m is not provided
|
||||
parser.add_argument(
|
||||
"positional_module",
|
||||
nargs="?", # This makes the positional argument optional
|
||||
help="Positional argument that is treated as module if -m is not specified.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
# If the -m/--module is not provided, fallback to the positional argument
|
||||
module = args.module if args.module else args.positional_module
|
||||
|
||||
if module:
|
||||
for app_name, app_module in find_apps(MODULE_PATH):
|
||||
if module in (app_name, app_module):
|
||||
print("Starting:", app_name)
|
||||
python_executable = sys.executable
|
||||
|
||||
# Replace the current process with the new Python module
|
||||
os.execvp(
|
||||
python_executable,
|
||||
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
|
||||
)
|
||||
print(f"Error: cannot find application {module}")
|
||||
|
||||
# display list of apps
|
||||
print("Available applications:")
|
||||
for app, _ in find_apps(MODULE_PATH):
|
||||
print(f" - {app}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
def dock_area(object_name: str | None = None) -> BECDockArea:
|
||||
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
|
||||
_dock_area = BECDockArea(object_name=object_name)
|
||||
return _dock_area
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
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 qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -21,30 +21,25 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
@@ -55,15 +50,9 @@ class LaunchTile(RoundedFrame):
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
# Provide a per‑instance TILE_SIZE so the class can compute layout
|
||||
if tile_size is None:
|
||||
tile_size = self.DEFAULT_SIZE
|
||||
self.tile_size = tile_size
|
||||
|
||||
self.icon_label = QLabel(parent=self)
|
||||
self.icon_label.setFixedSize(100, 100)
|
||||
self.icon_label.setScaledContents(True)
|
||||
@@ -94,26 +83,12 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
|
||||
# Desired default appearance
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setWordWrap(True)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
self.tile_size[0]
|
||||
- self.layout.contentsMargins().left()
|
||||
- self.layout.contentsMargins().right()
|
||||
)
|
||||
self._fit_label_to_width(self.main_label, content_width)
|
||||
|
||||
# Give every tile the same reserved height for the title so the
|
||||
# description labels start at an identical y‑offset.
|
||||
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
@@ -154,29 +129,6 @@ class LaunchTile(RoundedFrame):
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
Fit the label text to the specified maximum width by adjusting the font size.
|
||||
|
||||
Args:
|
||||
label(QLabel): The label to adjust.
|
||||
max_width(int): The maximum width the label can occupy.
|
||||
min_pt(int): The minimum font point size to use.
|
||||
"""
|
||||
font = label.font()
|
||||
for pt in range(font.pointSize(), min_pt - 1, -1):
|
||||
font.setPointSize(pt)
|
||||
metrics = QFontMetrics(font)
|
||||
if metrics.horizontalAdvance(label.text()) <= max_width:
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
return
|
||||
# If nothing fits, fall back to eliding
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
@@ -189,15 +141,12 @@ class LaunchWindow(BECMainWindow):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
@@ -207,124 +156,53 @@ class LaunchWindow(BECMainWindow):
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.register_tile(
|
||||
name="dock_area",
|
||||
self.tile_dock_area = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Dock Area",
|
||||
description="Highly flexible and customizable dock area application with modular widgets.",
|
||||
action_button=lambda: self.launch("dock_area"),
|
||||
show_selector=False,
|
||||
)
|
||||
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
self.register_tile(
|
||||
name="auto_update",
|
||||
self.tile_auto_update = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
|
||||
top_label="Get automated",
|
||||
main_label="BEC Auto Update Dock Area",
|
||||
description="Dock area with auto update functionality for BEC widgets plotting.",
|
||||
action_button=self._open_auto_update,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
|
||||
)
|
||||
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.register_tile(
|
||||
name="custom_ui_file",
|
||||
self.tile_ui_file = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
|
||||
top_label="Get customized",
|
||||
main_label="Launch Custom UI File",
|
||||
description="GUI application with custom UI file.",
|
||||
action_button=self._open_custom_ui_file,
|
||||
show_selector=False,
|
||||
)
|
||||
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
# plugin widgets
|
||||
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
|
||||
if self.available_widgets:
|
||||
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
|
||||
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
|
||||
self.register_tile(
|
||||
name="widget",
|
||||
icon_path=os.path.join(
|
||||
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
|
||||
),
|
||||
top_label="Get quickly started",
|
||||
main_label=f"Launch a {plugin_repo_name} Widget",
|
||||
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
|
||||
action_button=self._open_widget,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_widgets.keys()),
|
||||
)
|
||||
# Add tiles to the main layout
|
||||
self.central_widget.layout.addWidget(self.tile_dock_area)
|
||||
self.central_widget.layout.addWidget(self.tile_auto_update)
|
||||
self.central_widget.layout.addWidget(self.tile_ui_file)
|
||||
|
||||
# hacky solution no time to waste
|
||||
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
|
||||
|
||||
# Connect signals
|
||||
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
|
||||
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
|
||||
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
|
||||
self._update_theme()
|
||||
|
||||
self.register = RPCRegister()
|
||||
self.register.callbacks.append(self._turn_off_the_lights)
|
||||
self.register.broadcast()
|
||||
|
||||
def register_tile(
|
||||
self,
|
||||
name: str,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
action_button: Callable | None = None,
|
||||
show_selector: bool = False,
|
||||
selector_items: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Register a tile in the launcher window.
|
||||
|
||||
Args:
|
||||
name(str): The name of the tile.
|
||||
icon_path(str): The path to the icon.
|
||||
top_label(str): The top label of the tile.
|
||||
main_label(str): The main label of the tile.
|
||||
description(str): The description of the tile.
|
||||
action_button(callable): The action to be performed when the button is clicked.
|
||||
show_selector(bool): Whether to show a selector or not.
|
||||
selector_items(list[str]): The items to be shown in the selector.
|
||||
"""
|
||||
|
||||
tile = LaunchTile(
|
||||
icon_path=icon_path,
|
||||
top_label=top_label,
|
||||
main_label=main_label,
|
||||
description=description,
|
||||
show_selector=show_selector,
|
||||
tile_size=self.TILE_SIZE,
|
||||
# Auto updates
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
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
|
||||
if self.tile_auto_update.selector is not None:
|
||||
self.tile_auto_update.selector.addItems(
|
||||
list(self.available_auto_updates.keys()) + ["Default"]
|
||||
)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
@@ -332,7 +210,7 @@ class LaunchWindow(BECMainWindow):
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget | None:
|
||||
) -> QWidget:
|
||||
"""Launch the specified script. If the launch script creates a QWidget, it will be
|
||||
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
|
||||
as a separate window.
|
||||
@@ -353,8 +231,6 @@ class LaunchWindow(BECMainWindow):
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
@@ -366,20 +242,12 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
if launch_script == "custom_ui_file":
|
||||
ui_file = kwargs.pop("ui_file", None)
|
||||
if not ui_file:
|
||||
return None
|
||||
return self._launch_custom_ui_file(ui_file)
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update)
|
||||
|
||||
if launch_script == "widget":
|
||||
widget = kwargs.pop("widget", None)
|
||||
if widget is None:
|
||||
raise ValueError("Widget name must be provided.")
|
||||
return self._launch_widget(widget)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
raise ValueError(f"Launch script {launch_script} not found.")
|
||||
@@ -395,24 +263,17 @@ class LaunchWindow(BECMainWindow):
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window = BECMainWindow()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
"""
|
||||
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
|
||||
instantiate it directly; otherwise, embed it in a UILaunchWindow.
|
||||
"""
|
||||
# Load the custom UI file
|
||||
if ui_file is None:
|
||||
raise ValueError("UI file must be provided for custom UI file launch.")
|
||||
filename = os.path.basename(ui_file).split(".")[0]
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(filename)
|
||||
|
||||
# Parse the UI to detect top-level widget class
|
||||
tree = ET.parse(ui_file)
|
||||
root = tree.getroot()
|
||||
# Check if the top-level widget is a QMainWindow
|
||||
@@ -420,22 +281,19 @@ class LaunchWindow(BECMainWindow):
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
# Load the UI into a widget
|
||||
loader = UILoader(None)
|
||||
loaded = loader.loader(ui_file)
|
||||
|
||||
# Display the UI in a BECMainWindow
|
||||
if isinstance(loaded, BECMainWindow):
|
||||
window = loaded
|
||||
window.object_name = filename
|
||||
else:
|
||||
window = BECMainWindow(object_name=filename)
|
||||
window.setCentralWidget(loaded)
|
||||
if widget.attrib.get("class") == "QMainWindow":
|
||||
raise ValueError(
|
||||
"Loading a QMainWindow from a UI file is currently not supported. "
|
||||
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
|
||||
)
|
||||
|
||||
window = UILaunchWindow(object_name=filename)
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
result_widget = UILoader(window).loader(ui_file)
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {window.object_name}")
|
||||
window.show()
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
@@ -453,28 +311,11 @@ class LaunchWindow(BECMainWindow):
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
QApplication.processEvents()
|
||||
|
||||
window.setCentralWidget(widget_instance)
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
"""
|
||||
for tile in self.tiles.values():
|
||||
for tile in self.tiles:
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
@@ -483,25 +324,14 @@ class LaunchWindow(BECMainWindow):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
if self.tile_auto_update.selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tiles["auto_update"].selector.currentText()
|
||||
auto_update = self.tile_auto_update.selector.currentText()
|
||||
if auto_update == "Default":
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
Open a widget from the available widgets.
|
||||
"""
|
||||
if self.tiles["widget"].selector is None:
|
||||
return
|
||||
widget = self.tiles["widget"].selector.currentText()
|
||||
if widget not in self.available_widgets:
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
@@ -541,45 +371,6 @@ class LaunchWindow(BECMainWindow):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
remaining_connections = [
|
||||
connection for connection in connections.values() if connection.parent_id != self.gui_id
|
||||
]
|
||||
return len(remaining_connections) <= 4
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._show_examples = bool(show_examples)
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
# Mapping for view switching
|
||||
self._view_index: dict[str, int] = {}
|
||||
self._current_view_id: str | None = None
|
||||
self.sidebar.view_selected.connect(self._on_view_selected)
|
||||
|
||||
self._add_views()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(
|
||||
self, profile_namespace="main_workspace", auto_profile_namespace=False
|
||||
)
|
||||
self.ads.setObjectName("MainWorkspace")
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
|
||||
def add_separator(self):
|
||||
return self.sidebar.add_separator()
|
||||
|
||||
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
|
||||
return self.sidebar.add_dark_mode_item(id=id, position=position)
|
||||
|
||||
def add_view(
|
||||
self,
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
|
||||
|
||||
"""
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
if id in self._view_index:
|
||||
self.sidebar.activate_item(id)
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
|
||||
)
|
||||
|
||||
# Ask current view whether we may leave
|
||||
if current_view is not None and hasattr(current_view, "on_exit"):
|
||||
may_leave = current_view.on_exit()
|
||||
if may_leave is False:
|
||||
# Veto: restore previous highlight without re-emitting selection
|
||||
if self._current_view_id is not None:
|
||||
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
|
||||
return
|
||||
|
||||
# Proceed with switch
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
self.stack.setCurrentIndex(idx)
|
||||
new_view = self.stack.widget(idx)
|
||||
self._current_view_id = vid
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Main Application")
|
||||
parser.add_argument(
|
||||
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
|
||||
)
|
||||
# Let Qt consume the remaining args
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -1,114 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
|
||||
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
ANIMATION_DURATION = 500 # ms
|
||||
|
||||
|
||||
class RevealAnimator:
|
||||
"""Animate reveal/hide for a single widget using opacity + max W/H.
|
||||
|
||||
This keeps the widget always visible to avoid jitter from setVisible().
|
||||
Collapsed state: opacity=0, maxW=0, maxH=0.
|
||||
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: QWidget,
|
||||
duration: int = ANIMATION_DURATION,
|
||||
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
|
||||
initially_revealed: bool = False,
|
||||
*,
|
||||
animate_opacity: bool = True,
|
||||
animate_width: bool = True,
|
||||
animate_height: bool = True,
|
||||
):
|
||||
self.widget = widget
|
||||
self.animate_opacity = animate_opacity
|
||||
self.animate_width = animate_width
|
||||
self.animate_height = animate_height
|
||||
# Opacity effect
|
||||
self.fx = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(self.fx)
|
||||
# Animations
|
||||
self.opacity_anim = (
|
||||
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
|
||||
)
|
||||
self.width_anim = (
|
||||
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
|
||||
)
|
||||
self.height_anim = (
|
||||
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
|
||||
)
|
||||
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
|
||||
if anim is not None:
|
||||
anim.setDuration(duration)
|
||||
anim.setEasingCurve(easing)
|
||||
# Initialize to requested state
|
||||
self.set_immediate(initially_revealed)
|
||||
|
||||
def _natural_sizes(self) -> tuple[int, int]:
|
||||
sh = self.widget.sizeHint()
|
||||
w = max(sh.width(), 1)
|
||||
h = max(sh.height(), 1)
|
||||
return w, h
|
||||
|
||||
def set_immediate(self, revealed: bool):
|
||||
"""
|
||||
Immediately set the widget to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
revealed(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
w, h = self._natural_sizes()
|
||||
if self.animate_opacity:
|
||||
self.fx.setOpacity(1.0 if revealed else 0.0)
|
||||
if self.animate_width:
|
||||
self.widget.setMaximumWidth(w if revealed else 0)
|
||||
if self.animate_height:
|
||||
self.widget.setMaximumHeight(h if revealed else 0)
|
||||
|
||||
def setup(self, reveal: bool):
|
||||
"""
|
||||
Prepare animations to transition to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
reveal(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
# Prepare animations from current state to target
|
||||
target_w, target_h = self._natural_sizes()
|
||||
if self.opacity_anim is not None:
|
||||
self.opacity_anim.setStartValue(self.fx.opacity())
|
||||
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
|
||||
if self.width_anim is not None:
|
||||
self.width_anim.setStartValue(self.widget.maximumWidth())
|
||||
self.width_anim.setEndValue(target_w if reveal else 0)
|
||||
if self.height_anim is not None:
|
||||
self.height_anim.setStartValue(self.widget.maximumHeight())
|
||||
self.height_anim.setEndValue(target_h if reveal else 0)
|
||||
|
||||
def add_to_group(self, group: QParallelAnimationGroup):
|
||||
"""
|
||||
Add the prepared animations to the given animation group.
|
||||
|
||||
Args:
|
||||
group(QParallelAnimationGroup): The animation group to add to.
|
||||
"""
|
||||
if self.opacity_anim is not None:
|
||||
group.addAnimation(self.opacity_anim)
|
||||
if self.width_anim is not None:
|
||||
group.addAnimation(self.width_anim)
|
||||
if self.height_anim is not None:
|
||||
group.addAnimation(self.height_anim)
|
||||
|
||||
def animations(self):
|
||||
"""
|
||||
Get a list of all animations (non-None) for adding to a group.
|
||||
"""
|
||||
return [
|
||||
anim
|
||||
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
|
||||
if anim is not None
|
||||
]
|
||||
@@ -1,357 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
DarkModeNavItem,
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
SideBarSeparator,
|
||||
)
|
||||
|
||||
|
||||
class SideBar(QScrollArea):
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("SideBar")
|
||||
|
||||
# private attributes
|
||||
self._is_expanded = False
|
||||
self._collapsed_width = collapsed_width
|
||||
self._expanded_width = expanded_width
|
||||
self._anim_duration = anim_duration
|
||||
|
||||
# containers
|
||||
self.components = {}
|
||||
self._item_opts: dict[str, dict] = {}
|
||||
|
||||
# Scroll area properties
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(self._collapsed_width)
|
||||
|
||||
# Content widget holding buttons for switching views
|
||||
self.content = QWidget(self)
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(4)
|
||||
self.setWidget(self.content)
|
||||
|
||||
# Track active navigation item
|
||||
self._active_id = None
|
||||
|
||||
# Top row with title and toggle button
|
||||
self.toggle_row = QWidget(self)
|
||||
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
|
||||
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setObjectName("TopTitle")
|
||||
self.title_label.setStyleSheet("font-weight: 600;")
|
||||
self.title_fx = QGraphicsOpacityEffect(self.title_label)
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
|
||||
# To push the content up always
|
||||
self._bottom_spacer = QtWidgets.QSpacerItem(
|
||||
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
|
||||
# Add core widgets to layout
|
||||
self.content_layout.addWidget(self.toggle_row)
|
||||
self.content_layout.addItem(self._bottom_spacer)
|
||||
|
||||
# Animations
|
||||
self.width_anim = QPropertyAnimation(self, b"bar_width")
|
||||
self.width_anim.setDuration(self._anim_duration)
|
||||
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
|
||||
self.title_anim.setDuration(self._anim_duration)
|
||||
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.group = QParallelAnimationGroup(self)
|
||||
self.group.addAnimation(self.width_anim)
|
||||
self.group.addAnimation(self.title_anim)
|
||||
self.group.finished.connect(self._on_anim_finished)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
@SafeProperty(int)
|
||||
def bar_width(self) -> int:
|
||||
"""
|
||||
Get the current width of the side bar.
|
||||
|
||||
Returns:
|
||||
int: The current width of the side bar.
|
||||
"""
|
||||
return self.width()
|
||||
|
||||
@bar_width.setter
|
||||
def bar_width(self, width: int):
|
||||
"""
|
||||
Set the width of the side bar.
|
||||
|
||||
Args:
|
||||
width(int): The new width of the side bar.
|
||||
"""
|
||||
self.setFixedWidth(width)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def is_expanded(self) -> bool:
|
||||
"""
|
||||
Check if the side bar is expanded.
|
||||
|
||||
Returns:
|
||||
bool: True if the side bar is expanded, False otherwise.
|
||||
"""
|
||||
return self._is_expanded
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self):
|
||||
"""
|
||||
Toggle the expansion state of the side bar.
|
||||
"""
|
||||
self._is_expanded = not self._is_expanded
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
|
||||
if self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.group.stop()
|
||||
# Setting limits for animations of the side bar
|
||||
self.width_anim.setStartValue(self.width())
|
||||
self.width_anim.setEndValue(
|
||||
self._expanded_width if self._is_expanded else self._collapsed_width
|
||||
)
|
||||
self.title_anim.setStartValue(self.title_fx.opacity())
|
||||
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
|
||||
|
||||
# Setting limits for animations of the components
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "setup_animations"):
|
||||
comp.setup_animations(self._is_expanded)
|
||||
|
||||
self.group.start()
|
||||
if self._is_expanded:
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
self.toggled.emit(self._is_expanded)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_anim_finished(self):
|
||||
if not self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, theme_name: str):
|
||||
# Refresh toggle arrow icon so it picks up the new theme
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
# Refresh each component that supports it
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "refresh_theme"):
|
||||
comp.refresh_theme()
|
||||
else:
|
||||
comp.style().unpolish(comp)
|
||||
comp.style().polish(comp)
|
||||
comp.update()
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
|
||||
"""
|
||||
Add a section header to the side bar.
|
||||
|
||||
Args:
|
||||
title(str): The title of the section.
|
||||
id(str): Unique ID for the section.
|
||||
position(int, optional): Position to insert the section header.
|
||||
|
||||
Returns:
|
||||
SectionHeader: The created section header.
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = header
|
||||
return header
|
||||
|
||||
def add_separator(
|
||||
self, *, from_top: bool = True, position: int | None = None
|
||||
) -> SideBarSeparator:
|
||||
"""
|
||||
Add a separator line to the side bar. Separators are treated like regular
|
||||
items; you can place multiple separators anywhere using `from_top` and `position`.
|
||||
"""
|
||||
line = SideBarSeparator(self)
|
||||
line.setStyleSheet("margin:12px;")
|
||||
self._insert_nav_item(line, from_top=from_top, position=position)
|
||||
return line
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the nav item.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
"""
|
||||
item = NavigationItem(
|
||||
parent=self,
|
||||
title=title,
|
||||
icon_name=icon,
|
||||
mini_text=mini_text,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
# Connect activation to activation logic, passing id unchanged
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
target = self.components.get(target_id)
|
||||
if target is None:
|
||||
return
|
||||
# Non-toggleable acts like an action: do not change any toggled states
|
||||
if hasattr(target, "toggleable") and not target.toggleable:
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
|
||||
is_exclusive = getattr(target, "exclusive", True)
|
||||
if is_exclusive:
|
||||
# Radio-like behavior among exclusive items only
|
||||
for comp_id, comp in self.components.items():
|
||||
if not isinstance(comp, NavigationItem):
|
||||
continue
|
||||
if comp is target:
|
||||
comp.set_active(True)
|
||||
else:
|
||||
# Only untoggle other items that are also exclusive
|
||||
if getattr(comp, "exclusive", True):
|
||||
comp.set_active(False)
|
||||
# Leave non-exclusive items as they are
|
||||
else:
|
||||
# Non-exclusive toggles independently
|
||||
target.set_active(not target.is_active())
|
||||
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def add_dark_mode_item(
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> DarkModeNavItem:
|
||||
"""
|
||||
Add a dark mode toggle item to the side bar.
|
||||
|
||||
Args:
|
||||
id(str): Unique ID for the dark mode item.
|
||||
position(int, optional): Position to insert the dark mode item.
|
||||
|
||||
Returns:
|
||||
DarkModeNavItem: The created dark mode navigation item.
|
||||
"""
|
||||
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
|
||||
# compute bottom insertion point (same semantics as from_top=False)
|
||||
self._insert_nav_item(item, from_top=False, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def _insert_nav_item(
|
||||
self, item: QWidget, *, from_top: bool = True, position: int | None = None
|
||||
):
|
||||
if from_top:
|
||||
base_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
pos = base_index if position is None else min(base_index, position)
|
||||
else:
|
||||
base = self.content_layout.indexOf(self._bottom_spacer) + 1
|
||||
pos = base if position is None else base + max(0, position)
|
||||
self.content_layout.insertWidget(pos, item)
|
||||
@@ -1,372 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import (
|
||||
ANIMATION_DURATION,
|
||||
RevealAnimator,
|
||||
)
|
||||
|
||||
|
||||
def get_on_primary():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("ON_PRIMARY")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def get_fg():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("FG")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
class SideBarSeparator(QFrame):
|
||||
"""A horizontal line separator for use in SideBar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SideBarSeparator")
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(2)
|
||||
self.setProperty("variant", "separator")
|
||||
|
||||
|
||||
class SectionHeader(QWidget):
|
||||
"""A section header with a label and a horizontal line below."""
|
||||
|
||||
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SectionHeader")
|
||||
|
||||
self.lbl = QLabel(text, self)
|
||||
self.lbl.setObjectName("SectionHeaderLabel")
|
||||
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
|
||||
|
||||
self.line = SideBarSeparator(self)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
# keep your margins/spacing preferences here if needed
|
||||
lay.setContentsMargins(12, 0, 12, 0)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.lbl)
|
||||
lay.addWidget(self.line)
|
||||
|
||||
self.animations = self.build_animations()
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return self._reveal.animations()
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self._reveal.setup(expanded)
|
||||
|
||||
|
||||
class NavigationItem(QWidget):
|
||||
"""A nav tile with an icon + labels and an optional expandable body.
|
||||
Provides animations for collapsed/expanded sidebar states via
|
||||
build_animations()/setup_animations(), similar to SectionHeader.
|
||||
"""
|
||||
|
||||
activated = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
title: str,
|
||||
icon_name: str,
|
||||
mini_text: str | None = None,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavigationItem")
|
||||
|
||||
# Private attributes
|
||||
self._title = title
|
||||
self._icon_name = icon_name
|
||||
self._mini_text = mini_text or title
|
||||
self._toggleable = toggleable
|
||||
self._toggled = False
|
||||
self._exclusive = exclusive
|
||||
|
||||
# Main Icon
|
||||
self.icon_btn = QToolButton(self)
|
||||
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
|
||||
self.icon_btn.setAutoRaise(True)
|
||||
self._icon_size_collapsed = QtCore.QSize(20, 20)
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
self.mini_lbl.setObjectName("NavMiniLabel")
|
||||
self.mini_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.mini_lbl.setStyleSheet("font-size: 10px;")
|
||||
self.reveal_mini_lbl = RevealAnimator(
|
||||
widget=self.mini_lbl,
|
||||
initially_revealed=True,
|
||||
animate_width=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
|
||||
# Container for icon + mini label
|
||||
self.mini_icon = QWidget(self)
|
||||
mini_lay = QVBoxLayout(self.mini_icon)
|
||||
mini_lay.setContentsMargins(0, 2, 0, 2)
|
||||
mini_lay.setSpacing(2)
|
||||
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
|
||||
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
|
||||
|
||||
# Title label
|
||||
self.title_lbl = QLabel(self._title, self)
|
||||
self.title_lbl.setObjectName("NavTitleLabel")
|
||||
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.title_lbl.setStyleSheet("font-size: 13px;")
|
||||
self.reveal_title_lbl = RevealAnimator(
|
||||
widget=self.title_lbl,
|
||||
initially_revealed=False,
|
||||
animate_height=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(12, 2, 12, 2)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
|
||||
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
|
||||
self.icon_size_anim.setDuration(anim_duration)
|
||||
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
# Connect icon button to emit activation
|
||||
self.icon_btn.clicked.connect(self._emit_activated)
|
||||
self.setMouseTracking(True)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the item is currently active/selected."""
|
||||
return self.property("toggled") is True
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return (
|
||||
self.reveal_title_lbl.animations()
|
||||
+ self.reveal_mini_lbl.animations()
|
||||
+ [self.icon_size_anim]
|
||||
)
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self.reveal_mini_lbl.setup(not expanded)
|
||||
self.reveal_title_lbl.setup(expanded)
|
||||
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
|
||||
self.icon_size_anim.setEndValue(
|
||||
self._icon_size_expanded if expanded else self._icon_size_collapsed
|
||||
)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the title label."""
|
||||
self.title_lbl.setVisible(visible)
|
||||
|
||||
def _emit_activated(self):
|
||||
self.activated.emit()
|
||||
|
||||
def set_active(self, active: bool):
|
||||
"""
|
||||
Set the active/selected state of the item.
|
||||
|
||||
Args:
|
||||
active(bool): True to set active, False to deactivate.
|
||||
"""
|
||||
self.setProperty("toggled", active)
|
||||
self.toggled = active
|
||||
# ensure style refresh
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.activated.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggleable(self) -> bool:
|
||||
"""
|
||||
Whether the item is toggleable (like a button) or not (like an action).
|
||||
|
||||
Returns:
|
||||
bool: True if toggleable, False otherwise.
|
||||
"""
|
||||
return self._toggleable
|
||||
|
||||
@toggleable.setter
|
||||
def toggleable(self, value: bool):
|
||||
"""
|
||||
Set whether the item is toggleable (like a button) or not (like an action).
|
||||
Args:
|
||||
value(bool): True to make toggleable, False otherwise.
|
||||
"""
|
||||
self._toggleable = bool(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggled(self) -> bool:
|
||||
"""
|
||||
Whether the item is currently toggled/selected.
|
||||
|
||||
Returns:
|
||||
bool: True if toggled, False otherwise.
|
||||
"""
|
||||
return self._toggled
|
||||
|
||||
@toggled.setter
|
||||
def toggled(self, value: bool):
|
||||
"""
|
||||
Set whether the item is currently toggled/selected.
|
||||
|
||||
Args:
|
||||
value(bool): True to set toggled, False to untoggle.
|
||||
"""
|
||||
self._toggled = value
|
||||
if value:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
|
||||
)
|
||||
else:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
|
||||
)
|
||||
self.icon_btn.setIcon(new_icon)
|
||||
# Re-polish so QSS applies correct colors to icon/labels
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
Whether the item is exclusive in its toggle group.
|
||||
|
||||
Returns:
|
||||
bool: True if exclusive, False otherwise.
|
||||
"""
|
||||
return self._exclusive
|
||||
|
||||
@exclusive.setter
|
||||
def exclusive(self, value: bool):
|
||||
"""
|
||||
Set whether the item is exclusive in its toggle group.
|
||||
|
||||
Args:
|
||||
value(bool): True to make exclusive, False otherwise.
|
||||
"""
|
||||
self._exclusive = bool(value)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Recompute icon/label colors according to current theme and state
|
||||
# Trigger the toggled setter to rebuild the icon with the correct color
|
||||
self.toggled = self._toggled
|
||||
# Ensure QSS-driven text/icon colors refresh
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
|
||||
class DarkModeNavItem(NavigationItem):
|
||||
"""Bottom action item that toggles app theme and updates its icon/text."""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
title="Dark mode",
|
||||
icon_name="dark_mode",
|
||||
mini_text="Dark",
|
||||
toggleable=False, # action-like, no selection highlight changes
|
||||
exclusive=False,
|
||||
anim_duration=anim_duration,
|
||||
)
|
||||
self._id = id
|
||||
self._sync_from_qapp_theme()
|
||||
self.activated.connect(self.toggle_theme)
|
||||
|
||||
def _qapp_dark_enabled(self) -> bool:
|
||||
qapp = QApplication.instance()
|
||||
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
|
||||
|
||||
def _sync_from_qapp_theme(self):
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
# Update labels
|
||||
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
|
||||
self.mini_lbl.setText("Light" if is_dark else "Dark")
|
||||
# Update icon
|
||||
self.icon_btn.setIcon(
|
||||
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
|
||||
)
|
||||
|
||||
def refresh_theme(self):
|
||||
self._sync_from_qapp_theme()
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Toggle application theme and update icon/text."""
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
|
||||
apply_theme("light" if is_dark else "dark")
|
||||
self._sync_from_qapp_theme()
|
||||
@@ -1,60 +0,0 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
"""
|
||||
A view for users to write scripts and macros and execute them within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,383 +0,0 @@
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> str:
|
||||
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
|
||||
|
||||
# Preprocess: convert consecutive >>> lines to Python code blocks
|
||||
def replace_python_examples(match):
|
||||
indent = match.group(1)
|
||||
examples = match.group(2)
|
||||
# Remove >>> prefix and clean up the code
|
||||
lines = []
|
||||
for line in examples.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(">>> "):
|
||||
lines.append(line[4:]) # Remove '>>> '
|
||||
elif line.startswith(">>>"):
|
||||
lines.append(line[3:]) # Remove '>>>'
|
||||
code = "\n".join(lines)
|
||||
|
||||
return f"{indent}```python\n{indent}{code}\n{indent}```"
|
||||
|
||||
# Match one or more consecutive >>> lines (with same indentation)
|
||||
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
|
||||
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
|
||||
|
||||
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
|
||||
html = markdown.markdown(
|
||||
md_text,
|
||||
extensions=extensions,
|
||||
extension_configs={
|
||||
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
|
||||
},
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
# Remove hardcoded background colors that conflict with themes
|
||||
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
|
||||
html = re.sub(r"background: #[^;]*;", "", html)
|
||||
|
||||
# Add CSS to force code blocks to wrap
|
||||
css = """
|
||||
<style>
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
.codehilite pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
return css + html
|
||||
|
||||
|
||||
class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||
|
||||
# Promote toolbar above the dock manager provided by the base class
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.explorer.setObjectName("Explorer")
|
||||
self.console = WebConsole(self)
|
||||
self.console.setObjectName("Console")
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = AdvancedDockArea(
|
||||
self,
|
||||
mode="plot",
|
||||
default_add_direction="bottom",
|
||||
profile_namespace="developer_plotting",
|
||||
auto_profile_namespace=False,
|
||||
enable_profile_management=False,
|
||||
variant="compact",
|
||||
)
|
||||
self.plotting_ads.setObjectName("PlottingArea")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setObjectName("Signature Help")
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
self.monaco.signature_help.connect(
|
||||
lambda text: self.signature_help.setHtml(markdown_to_html(text))
|
||||
)
|
||||
self._current_script_id: str | None = None
|
||||
self.script_editor_tab = None
|
||||
|
||||
self._initialize_layout()
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def _initialize_layout(self) -> None:
|
||||
"""Create the default dock arrangement for the developer workspace."""
|
||||
|
||||
# Monaco editor as the central dock
|
||||
self.monaco_dock = self.new(
|
||||
self.monaco,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
show_settings_action=False,
|
||||
title_buttons={"float": False, "close": False, "menu": False},
|
||||
# promote_central=True,
|
||||
)
|
||||
|
||||
# Explorer on the left without a title bar
|
||||
self.explorer_dock = self.new(
|
||||
self.explorer,
|
||||
where="left",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Console and terminal tabbed along the bottom
|
||||
self.console_dock = self.new(
|
||||
self.console,
|
||||
relative_to=self.monaco_dock,
|
||||
where="bottom",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True, "close": False},
|
||||
)
|
||||
self.terminal_dock = self.new(
|
||||
self.terminal,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.console_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
# Plotting area on the right with signature help tabbed alongside
|
||||
self.plotting_ads_dock = self.new(
|
||||
self.plotting_ads,
|
||||
where="right",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True},
|
||||
)
|
||||
self.signature_dock = self.new(
|
||||
self.signature_help,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.plotting_ads_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(
|
||||
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
|
||||
)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(
|
||||
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
save_as_button.action.triggered.connect(self.on_save_as)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow",
|
||||
tooltip="Run current file",
|
||||
label_text="Run",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
label_text="Stop",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim",
|
||||
tooltip="Toggle Vim Mode",
|
||||
label_text="Vim",
|
||||
filled=True,
|
||||
parent=self,
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name, scope)
|
||||
|
||||
# Set read-only mode for shared files
|
||||
if "shared" in scope:
|
||||
self.monaco.set_file_readonly(file_name, True)
|
||||
|
||||
# Add appropriate icon based on file type
|
||||
if "script" in scope:
|
||||
# Use script icon for script files
|
||||
icon = material_icon("script", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
elif "macro" in scope:
|
||||
# Use function icon for macro files
|
||||
icon = material_icon("function", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor."""
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
self.current_script_id = upload_script(self.client.connector, widget.get_text())
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
if not self.current_script_id:
|
||||
return
|
||||
self.console.send_ctrl_c()
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value: str | None):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
old_script_id = self._current_script_id
|
||||
self._current_script_id = value
|
||||
self._update_subscription(value, old_script_id)
|
||||
|
||||
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
|
||||
if old_script_id is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
|
||||
)
|
||||
if new_script_id is not None:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Handle script execution info messages to update the editor highlights.
|
||||
Args:
|
||||
content (dict): The content of the message containing execution info.
|
||||
metadata (dict): Additional metadata for the message.
|
||||
"""
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if self.script_editor_tab is None:
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
if not current_lines:
|
||||
widget.clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
widget.clear_highlighted_lines()
|
||||
widget.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def cleanup(self):
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,2 +0,0 @@
|
||||
from .config_choice_dialog import ConfigChoiceDialog
|
||||
from .device_form_dialog import DeviceFormDialog
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Dialog to choose config loading method: replace, add or cancel."""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
class Result(IntEnum):
|
||||
CANCEL = QDialog.Rejected
|
||||
ADD = 2
|
||||
REPLACE = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
custom_label: str = "Do you want to replace the current config or add to it?",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel(custom_label)
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Use QDialogButtonBox for native layout
|
||||
self.button_box = QDialogButtonBox(self)
|
||||
self.cancel_btn = self.button_box.addButton(
|
||||
"Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept...
|
||||
)
|
||||
self.replace_btn = self.button_box.addButton(
|
||||
"Replace", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
|
||||
layout.addWidget(self.button_box)
|
||||
|
||||
for btn in [self.replace_btn, self.add_btn, self.cancel_btn]:
|
||||
btn.setMinimumWidth(80)
|
||||
btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
# Connections using native done(int)
|
||||
self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE))
|
||||
self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD))
|
||||
self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL))
|
||||
|
||||
self.replace_btn.setFocus()
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Dialogs for device configuration forms and ophyd testing."""
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
|
||||
DeviceConfigTemplate,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
|
||||
validate_name,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
format_error_to_md,
|
||||
)
|
||||
|
||||
DEFAULT_DEVICE = "CustomDevice"
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
"""Popup dialog to test Ophyd device configurations interactively."""
|
||||
|
||||
def __init__(self, parent=None, config: dict | None = None): # type:ignore
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Device Manager Ophyd Test")
|
||||
self._config_status = ConfigStatus.UNKNOWN.value
|
||||
self._connection_status = ConnectionStatus.UNKNOWN.value
|
||||
self._validated_config: dict = {}
|
||||
self._validation_msg: str = ""
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Core test widget
|
||||
self.device_manager_ophyd_test = OphydValidation()
|
||||
layout.addWidget(self.device_manager_ophyd_test)
|
||||
|
||||
# Log/Markdown box for messages
|
||||
self.text_box = QtWidgets.QTextEdit()
|
||||
self.text_box.setReadOnly(True)
|
||||
layout.addWidget(self.text_box)
|
||||
|
||||
# Connect signal for validation messages
|
||||
|
||||
# Load and apply configuration
|
||||
config = config or {}
|
||||
self.device_manager_ophyd_test.change_device_configs([config], True, True)
|
||||
|
||||
# Dialog Buttons: equal size, stacked horizontally
|
||||
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close)
|
||||
for button in button_box.buttons():
|
||||
button.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed
|
||||
)
|
||||
button.clicked.connect(self.accept)
|
||||
# button_box.setCenterButtons(False)
|
||||
layout.addWidget(button_box)
|
||||
self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated)
|
||||
self._resize_dialog()
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
def _resize_dialog(self):
|
||||
"""Resize the dialog based on the screen size."""
|
||||
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 4:3 ratio
|
||||
height = int(screen_height * 0.7)
|
||||
width = int(height * (4 / 3))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (4 / 3))
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
def _on_device_validated(
|
||||
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
|
||||
):
|
||||
device_name = device_config.get("name", "")
|
||||
self._config_status = config_status
|
||||
self._connection_status = connection_status
|
||||
self._validated_config = device_config
|
||||
self._validation_msg = validation_msg
|
||||
self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg))
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
self.device_manager_ophyd_test.close()
|
||||
self.device_manager_ophyd_test.deleteLater()
|
||||
|
||||
@property
|
||||
def validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""
|
||||
Return the result of the validation as a tuple of
|
||||
|
||||
Returns:
|
||||
result (Tuple[dict, int, int]): A tuple containing:
|
||||
validated_config (dict): The validated device configuration.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
|
||||
"""
|
||||
return (
|
||||
self._validated_config,
|
||||
self._config_status,
|
||||
self._connection_status,
|
||||
self._validation_msg,
|
||||
)
|
||||
|
||||
|
||||
class DeviceFormDialog(QtWidgets.QDialog):
|
||||
|
||||
# Signal emitted when device configuration is accepted, only
|
||||
# emitted when the user clicks the "Add Device" button
|
||||
# The integer values indicate if the device config was
|
||||
# validated: config_status, connection_status
|
||||
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
||||
|
||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore
|
||||
super().__init__(parent)
|
||||
# Track old device name if config is edited
|
||||
self._old_device_name: str = ""
|
||||
|
||||
# Config validation result
|
||||
self._validation_result: tuple[dict, int, int, str] = (
|
||||
{},
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
"",
|
||||
)
|
||||
# Group to variants mapping
|
||||
self._group_variants: dict[str, list[str]] = {
|
||||
group: [variant for variant in variants.keys()]
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items()
|
||||
}
|
||||
|
||||
self._control_widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
# Setup layout
|
||||
self.setWindowTitle("Device Config Dialog")
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Control panel
|
||||
self._control_box = self.create_control_panel()
|
||||
layout.addWidget(self._control_box)
|
||||
|
||||
# Device config template display
|
||||
self._device_config_template = DeviceConfigTemplate(parent=self)
|
||||
self._frame = QtWidgets.QFrame()
|
||||
self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
self._frame.setFrameShadow(QtWidgets.QFrame.Raised)
|
||||
frame_layout = QtWidgets.QVBoxLayout(self._frame)
|
||||
frame_layout.addWidget(self._device_config_template)
|
||||
layout.addWidget(self._frame)
|
||||
|
||||
# Custom buttons
|
||||
self.add_btn = QtWidgets.QPushButton(add_btn_text)
|
||||
self.test_connection_btn = QtWidgets.QPushButton("Test Connection")
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.reset_btn = QtWidgets.QPushButton("Reset Form")
|
||||
|
||||
btn_layout = QtWidgets.QHBoxLayout()
|
||||
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
|
||||
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
btn_box = QtWidgets.QGroupBox("Actions")
|
||||
btn_box.setLayout(btn_layout)
|
||||
frame_layout.addWidget(btn_box)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.add_btn.clicked.connect(self._add_config)
|
||||
self.test_connection_btn.clicked.connect(self._test_connection)
|
||||
self.reset_btn.clicked.connect(self._reset_config)
|
||||
self.cancel_btn.clicked.connect(self._reject_config)
|
||||
|
||||
# layout.addWidget(self._device_config_template)
|
||||
self.update_variant_combo(self._control_widgets["group_combo"].currentText())
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
for widget in self._control_widgets.values():
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
@property
|
||||
def config_validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""Return the result of the last configuration validation."""
|
||||
return self._validation_result
|
||||
|
||||
@config_validation_result.setter
|
||||
def config_validation_result(self, result: tuple[dict, int, int, str]):
|
||||
self._validation_result = result
|
||||
|
||||
def set_device_config(self, device_config: dict):
|
||||
"""Set the device configuration in the template form."""
|
||||
# Figure out which group and variant this config belongs to
|
||||
device_class = device_config.get("deviceClass", None)
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items():
|
||||
for variant, template_info in variants.items():
|
||||
if template_info.get("deviceClass", None) == device_class:
|
||||
# Found the matching group and variant
|
||||
self._control_widgets["group_combo"].setCurrentText(group)
|
||||
self.update_variant_combo(group)
|
||||
self._control_widgets["variant_combo"].setCurrentText(variant)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
return
|
||||
# If no match found, set to default
|
||||
self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE)
|
||||
self.update_variant_combo(DEFAULT_DEVICE)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
self._old_device_name = device_config.get("name", "")
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
return QtCore.QSize(1600, 1000)
|
||||
|
||||
def create_control_panel(self) -> QtWidgets.QGroupBox:
|
||||
self._control_box = QtWidgets.QGroupBox("Choose a Device Group")
|
||||
layout = QtWidgets.QGridLayout(self._control_box)
|
||||
|
||||
group_label = QtWidgets.QLabel("Device Group:")
|
||||
layout.addWidget(group_label, 0, 0)
|
||||
|
||||
group_combo = QtWidgets.QComboBox()
|
||||
group_combo.addItems(self._group_variants.keys())
|
||||
self._control_widgets["group_combo"] = group_combo
|
||||
layout.addWidget(group_combo, 1, 0)
|
||||
|
||||
variant_label = QtWidgets.QLabel("Variants:")
|
||||
layout.addWidget(variant_label, 0, 1)
|
||||
|
||||
variant_combo = QtWidgets.QComboBox()
|
||||
self._control_widgets["variant_combo"] = variant_combo
|
||||
layout.addWidget(variant_combo, 1, 1)
|
||||
|
||||
group_combo.currentTextChanged.connect(self.update_variant_combo)
|
||||
variant_combo.currentTextChanged.connect(self.update_device_config_template)
|
||||
|
||||
return self._control_box
|
||||
|
||||
def update_variant_combo(self, group_name: str):
|
||||
variant_combo = self._control_widgets["variant_combo"]
|
||||
variant_combo.clear()
|
||||
variant_combo.addItems(self._group_variants.get(group_name, []))
|
||||
if variant_combo.count() <= 1:
|
||||
variant_combo.setEnabled(False)
|
||||
else:
|
||||
variant_combo.setEnabled(True)
|
||||
|
||||
def update_device_config_template(self, variant_name: str):
|
||||
group_name = self._control_widgets["group_combo"].currentText()
|
||||
template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {})
|
||||
if template_info:
|
||||
self._device_config_template.change_template(template_info)
|
||||
else:
|
||||
self._device_config_template.change_template(
|
||||
OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE]
|
||||
)
|
||||
|
||||
def _add_config(self):
|
||||
config = self._device_config_template.get_config_fields()
|
||||
config_status = ConfigStatus.UNKNOWN.value
|
||||
connection_status = ConnectionStatus.UNKNOWN.value
|
||||
validation_msg = ""
|
||||
try:
|
||||
if DeviceModel.model_validate(config) == DeviceModel.model_validate(
|
||||
self._validation_result[0]
|
||||
):
|
||||
config_status = self._validation_result[1]
|
||||
connection_status = self._validation_result[2]
|
||||
validation_msg = self._validation_result[3]
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses."
|
||||
)
|
||||
|
||||
if not validate_name(config.get("name", "")):
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Name",
|
||||
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Configuration",
|
||||
f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
self.accepted_data.emit(
|
||||
config, config_status, connection_status, validation_msg, self._old_device_name
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox:
|
||||
msg_box = QtWidgets.QMessageBox(self)
|
||||
msg_box.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
return msg_box
|
||||
|
||||
def _test_connection(self):
|
||||
config = self._device_config_template.get_config_fields()
|
||||
dialog = DeviceManagerOphydValidationDialog(self, config=config)
|
||||
result = dialog.exec()
|
||||
if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected):
|
||||
self.config_validation_result = dialog.validation_result
|
||||
# self._device_config_template.set_config_fields(self.config_validation_result[0])
|
||||
|
||||
def _reset_config(self):
|
||||
self._device_config_template.reset_to_defaults()
|
||||
|
||||
def _reject_config(self):
|
||||
self.reject()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = DeviceFormDialog()
|
||||
dialog.resize(1200, 800)
|
||||
dialog.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,720 +0,0 @@
|
||||
"""Module for the upload redis dialog in the device manager view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Dict, List, Tuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
get_validation_icons,
|
||||
)
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.colors import AccentColor
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceStatusItem(QtWidgets.QWidget):
|
||||
"""Individual device status item widget for the validation display."""
|
||||
|
||||
def __init__(
|
||||
self, device_config: dict, config_status: int, connection_status: int, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.device_name = device_config.get("name", "")
|
||||
self.device_config: dict = device_config
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
# Get validation icons
|
||||
self.colors = get_accent_colors()
|
||||
self._icon_size = (20, 20)
|
||||
self.icons = get_validation_icons(self.colors, self._icon_size)
|
||||
|
||||
self._setup_ui()
|
||||
self._update_display()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the device status item."""
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 4, 8, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Device name label
|
||||
self.name_label = QtWidgets.QLabel(self.device_name)
|
||||
self.name_label.setMinimumWidth(150)
|
||||
layout.addWidget(self.name_label)
|
||||
layout.addStretch()
|
||||
|
||||
# Config status icon
|
||||
self.config_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.config_icon_label)
|
||||
|
||||
# Connection status icon
|
||||
self.connection_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.connection_icon_label)
|
||||
|
||||
def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton()
|
||||
button.setFlat(True)
|
||||
button.setEnabled(False)
|
||||
button.setStyleSheet(self._transparent_button_style)
|
||||
button.setFixedSize(icon_size[0], icon_size[1])
|
||||
return button
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the visual display based on current status."""
|
||||
# Update config status
|
||||
config_icon = self.icons["config_status"].get(self.config_status.value)
|
||||
if config_icon:
|
||||
self.config_icon_label.setIcon(config_icon)
|
||||
|
||||
# Update connection status
|
||||
connection_icon = self.icons["connection_status"].get(self.connection_status.value)
|
||||
if connection_icon:
|
||||
self.connection_icon_label.setIcon(connection_icon)
|
||||
|
||||
def update_status(self, config_status: int, connection_status: int):
|
||||
"""Update the status and refresh display."""
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._update_display()
|
||||
|
||||
|
||||
class SortTableItem(QtWidgets.QTableWidgetItem):
|
||||
"""Custom TableWidgetItem with hidden __column_data attribute for sorting."""
|
||||
|
||||
def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status < other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status < other_data.connection_status
|
||||
return super().__lt__(other)
|
||||
|
||||
def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status > other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status > other_data.connection_status
|
||||
return super().__gt__(other)
|
||||
|
||||
|
||||
class ValidationSection(QtWidgets.QGroupBox):
|
||||
"""Section widget for displaying validation results."""
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(title, parent=parent)
|
||||
self._setup_ui()
|
||||
# self.device_items: Dict[str, DeviceStatusItem] = {}
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the validation section."""
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Status summary label
|
||||
summary_layout = QtWidgets.QHBoxLayout()
|
||||
self.summary_icon = QtWidgets.QLabel()
|
||||
self.summary_icon.setFixedSize(24, 24)
|
||||
self.summary_label = QtWidgets.QLabel()
|
||||
self.summary_label.setWordWrap(True)
|
||||
summary_layout.addWidget(self.summary_icon)
|
||||
summary_layout.addWidget(self.summary_label)
|
||||
layout.addLayout(summary_layout)
|
||||
|
||||
# Scroll area for device items
|
||||
self.table = QtWidgets.QTableWidget()
|
||||
self.table.setColumnCount(1)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().hide()
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.setShowGrid(False) # r
|
||||
self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
layout.addWidget(self.table)
|
||||
QtCore.QTimer.singleShot(0, self.adjustSize)
|
||||
|
||||
def add_device(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""
|
||||
Add a device to the validation section.
|
||||
|
||||
Args:
|
||||
device_config (dict): The device configuration dictionary.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
"""
|
||||
self.table.setSortingEnabled(False)
|
||||
device_name = device_config.get("name", "")
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
widget.update_status(config_status, connection_status)
|
||||
else:
|
||||
row_position = self.table.rowCount()
|
||||
self.table.insertRow(row_position)
|
||||
sort_item = SortTableItem(device_name)
|
||||
sort_item.setText("")
|
||||
self.table.setItem(row_position, 0, sort_item)
|
||||
device_item = DeviceStatusItem(device_config, config_status, connection_status)
|
||||
sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item)
|
||||
self.table.setCellWidget(row_position, 0, device_item)
|
||||
self.table.resizeRowsToContents()
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def _find_row_by_name(self, device_name: str) -> int | None:
|
||||
"""
|
||||
Find a row by device name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the device to find.
|
||||
Returns:
|
||||
int | None: The row index if found, else None.
|
||||
"""
|
||||
for row in range(self.table.rowCount()):
|
||||
item: SortTableItem = self.table.item(row, 0)
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
if widget.device_name == device_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the table by name."""
|
||||
self.table.setSortingEnabled(False)
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
self.table.removeRow(row)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def clear_devices(self):
|
||||
"""Clear all device items."""
|
||||
self.table.setSortingEnabled(False)
|
||||
while self.table.rowCount() > 0:
|
||||
self.table.removeRow(0)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def update_summary(self, text: str, icon: QtGui.QPixmap = None):
|
||||
"""Update the summary label."""
|
||||
self.summary_label.setText(text)
|
||||
if icon:
|
||||
self.summary_icon.setPixmap(icon)
|
||||
|
||||
|
||||
class UploadRedisDialog(QtWidgets.QDialog):
|
||||
"""
|
||||
Dialog for uploading device configurations to BEC server with validation checks.
|
||||
"""
|
||||
|
||||
class UploadAction(IntEnum):
|
||||
"""Enum for upload actions."""
|
||||
|
||||
CANCEL = QtWidgets.QDialog.Rejected
|
||||
OK = QtWidgets.QDialog.Accepted
|
||||
|
||||
# Signal to trigger upload after confirmation
|
||||
upload_confirmed = QtCore.Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
ophyd_test_widget: OphydValidation,
|
||||
device_configs: dict[str, Tuple[dict, int, int]] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {}
|
||||
self.ophyd_test_widget = ophyd_test_widget
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
self.colors = get_accent_colors()
|
||||
self.icons = get_validation_icons(self.colors, (20, 20))
|
||||
material_icon_partial = partial(material_icon, size=(24, 24), filled=True)
|
||||
self._label_icons = {
|
||||
"success": material_icon_partial("check_circle", color=self.colors.success),
|
||||
"warning": material_icon_partial("warning", color=self.colors.warning),
|
||||
"error": material_icon_partial("error", color=self.colors.emergency),
|
||||
"reload": material_icon_partial("refresh", color=self.colors.default),
|
||||
"upload": material_icon_partial("cloud_upload", color=self.colors.default),
|
||||
}
|
||||
|
||||
# Track validation states
|
||||
self.has_invalid_configs: int = 0
|
||||
self.has_untested_connections: int = 0
|
||||
self.has_cannot_connect: int = 0
|
||||
self._current_progress: int | None = None
|
||||
|
||||
self._setup_ui()
|
||||
self._update_ui()
|
||||
# Disable validation features if no ophyd test widget provided, else connect validation
|
||||
self._validation_connection = self.ophyd_test_widget.validation_completed.connect(
|
||||
self._update_from_ophyd_device_tests
|
||||
)
|
||||
|
||||
def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]):
|
||||
"""
|
||||
Update the device configuration in the dialog.
|
||||
|
||||
Args:
|
||||
device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure
|
||||
{device_name: (config_dict, config_status, connection_status)}.
|
||||
"""
|
||||
self.config_section.clear_devices()
|
||||
self.device_configs = device_configs
|
||||
self._update_ui()
|
||||
|
||||
def accept(self):
|
||||
self.cleanup()
|
||||
return super().accept()
|
||||
|
||||
def reject(self):
|
||||
self.cleanup()
|
||||
return super().reject()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup on dialog finish."""
|
||||
self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the main UI for the dialog."""
|
||||
self.setWindowTitle("Upload Configuration to BEC Server")
|
||||
self.setModal(True) # Blocks interaction with other parts of the app
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header_label = QtWidgets.QLabel("Review Configuration Before Upload")
|
||||
header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;")
|
||||
layout.addWidget(header_label)
|
||||
|
||||
# Description
|
||||
desc_label = QtWidgets.QLabel(
|
||||
"Please review the configuration and connection status of all devices before uploading to BEC Server."
|
||||
)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setStyleSheet("color: #666; margin-bottom: 16px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Config validation section
|
||||
sections_layout = QtWidgets.QHBoxLayout()
|
||||
self.config_section = ValidationSection("Configuration Validation")
|
||||
sections_layout.addWidget(self.config_section)
|
||||
layout.addLayout(sections_layout)
|
||||
|
||||
# Action buttons section
|
||||
self._setup_action_buttons(layout)
|
||||
|
||||
# Dialog buttons
|
||||
self._setup_dialog_buttons(layout)
|
||||
self.adjustSize()
|
||||
|
||||
def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the action buttons section."""
|
||||
action_group = QtWidgets.QGroupBox("Actions")
|
||||
action_layout = QtWidgets.QVBoxLayout(action_group)
|
||||
|
||||
# Validate connections button
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections")
|
||||
self.validate_connections_btn.setIcon(self._label_icons["reload"])
|
||||
self.validate_connections_btn.clicked.connect(self._validate_connections)
|
||||
button_layout.addWidget(self.validate_connections_btn)
|
||||
button_layout.addStretch()
|
||||
button_layout.addSpacing(16)
|
||||
|
||||
# Progress bar
|
||||
self._progress_bar = BECProgressBar(self)
|
||||
self._progress_bar.setVisible(False)
|
||||
button_layout.addWidget(self._progress_bar)
|
||||
action_layout.addLayout(button_layout)
|
||||
|
||||
# Status indicator
|
||||
status_layout = QtWidgets.QHBoxLayout()
|
||||
self.status_icon = QtWidgets.QPushButton()
|
||||
self.status_icon.setFlat(True)
|
||||
self.status_icon.setEnabled(False)
|
||||
self.status_icon.setStyleSheet(self._transparent_button_style)
|
||||
self.status_icon.setFixedSize(24, 24)
|
||||
self.status_label = QtWidgets.QLabel()
|
||||
self.status_label.setWordWrap(True)
|
||||
status_layout.addWidget(self.status_icon)
|
||||
status_layout.addWidget(self.status_label)
|
||||
action_layout.addLayout(status_layout)
|
||||
|
||||
parent_layout.addWidget(action_group)
|
||||
|
||||
def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the dialog buttons."""
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
# Upload button
|
||||
self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server")
|
||||
self.upload_btn.setIcon(self._label_icons["upload"])
|
||||
self.upload_btn.clicked.connect(self._handle_upload)
|
||||
button_layout.addWidget(self.upload_btn)
|
||||
|
||||
parent_layout.addLayout(button_layout)
|
||||
|
||||
def _populate_device_data(self):
|
||||
"""Populate the dialog with device configuration data."""
|
||||
if not self.device_configs:
|
||||
return
|
||||
|
||||
self.has_invalid_configs = 0
|
||||
self.has_untested_connections = 0
|
||||
self.has_cannot_connect = 0
|
||||
|
||||
for device_name, (config, config_status, connection_status) in self.device_configs.items():
|
||||
# Add to appropriate sections
|
||||
self.config_section.add_device(config, config_status, connection_status)
|
||||
|
||||
# Track statistics
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
self.has_invalid_configs += 1
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.has_untested_connections += 1
|
||||
if connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
self.has_cannot_connect += 1
|
||||
|
||||
# Update section summaries
|
||||
num_devices = len(self.device_configs)
|
||||
|
||||
# Config validation summary
|
||||
if self.has_invalid_configs > 0:
|
||||
icon = self._label_icons["error"]
|
||||
text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
icon = self._label_icons["success"]
|
||||
text = f"All {num_devices} device configurations are valid."
|
||||
if self.has_untested_connections > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_untested_connections} device connections are not tested."
|
||||
if self.has_cannot_connect > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_cannot_connect} device connections cannot be established."
|
||||
self.config_section.update_summary(text, icon)
|
||||
|
||||
def _update_ui(self):
|
||||
"""Update UI state based on validation results."""
|
||||
# Update first the device data
|
||||
self._populate_device_data()
|
||||
|
||||
# Invalid configuration have highest priority, upload disabled
|
||||
if self.has_invalid_configs:
|
||||
self.status_icon.setIcon(self._label_icons["error"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_invalid_configs} device configurations are invalid.",
|
||||
"Please fix configuration errors before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("Invalid Configurations")
|
||||
|
||||
# Next priority: connections that cannot be established, error but upload is enabled
|
||||
elif self.has_cannot_connect:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_cannot_connect} connections cannot be established.",
|
||||
"Please fix connection issues before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# Next priority: untested connections, warning but upload is enabled
|
||||
elif self.has_untested_connections:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_untested_connections} connections have not been tested.",
|
||||
"Consider validating connections before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# All good, upload enabled
|
||||
else:
|
||||
self.status_icon.setIcon(self._label_icons["success"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
"All device configurations are valid.",
|
||||
"All connections have been successfully tested.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("All Connections Validated")
|
||||
|
||||
@SafeSlot()
|
||||
def _validate_connections(self):
|
||||
"""Request validation of all untested connections."""
|
||||
testable_devices: List[dict] = []
|
||||
for _, (config, _, connection_status) in self.device_configs.items():
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
testable_devices.append(config)
|
||||
elif connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
testable_devices.append(config)
|
||||
|
||||
if len(testable_devices) > 0:
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self._progress_bar.setVisible(True)
|
||||
self._progress_bar.maximum = len(testable_devices)
|
||||
self._progress_bar.minimum = 0
|
||||
self._progress_bar.set_value(0)
|
||||
self._current_progress = 0
|
||||
self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True)
|
||||
|
||||
@SafeSlot()
|
||||
def _handle_upload(self):
|
||||
"""Handle the upload button click with appropriate confirmations."""
|
||||
# First priority: invalid configurations, block upload
|
||||
if self.has_invalid_configs:
|
||||
detailed_text = (
|
||||
f"There is {self.has_invalid_configs} device with an invalid configuration."
|
||||
if self.has_invalid_configs == 1
|
||||
else f"There are {self.has_invalid_configs} devices with invalid configurations."
|
||||
)
|
||||
text = " ".join(
|
||||
[detailed_text, "Invalid configuration can not be uploaded to the BEC Server."]
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text)
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
return
|
||||
|
||||
# Next priority: connections that cannot be established, show warning, but allow to proceed
|
||||
if self.has_cannot_connect:
|
||||
detailed_text = (
|
||||
f"There is {self.has_cannot_connect} device that cannot connect"
|
||||
if self.has_cannot_connect == 1
|
||||
else f"There are {self.has_cannot_connect} devices that cannot connect."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"These devices may not be reachable and disabled BEC upon loading the config.",
|
||||
"Consider validating these connections before.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.critical(
|
||||
self,
|
||||
"Devices cannot Connect",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# If some connections are untested, warn the user
|
||||
if self.has_untested_connections:
|
||||
detailed_text = (
|
||||
f"There is {self.has_untested_connections} device with untested connections."
|
||||
if self.has_untested_connections == 1
|
||||
else f"There are {self.has_untested_connections} devices with untested connections."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Untested Connections",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# Final confirmation
|
||||
text = " ".join(
|
||||
["You are about to upload the device configurations to BEC Server.", "Please confirm."]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Upload to BEC Server",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.Yes,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.Yes:
|
||||
self.done(self.UploadAction.OK)
|
||||
else:
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _update_from_ophyd_device_tests(
|
||||
self,
|
||||
device_config: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
validation_message: str = "",
|
||||
):
|
||||
"""
|
||||
Update device status from ophyd device tests. This has to be with a connection_status that was updated.
|
||||
|
||||
"""
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
return
|
||||
self.update_device_status(device_config, config_status, connection_status)
|
||||
|
||||
@SafeSlot(dict, int, int)
|
||||
def update_device_status(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""Update the status of a specific device."""
|
||||
# Update device config status
|
||||
device_name = device_config.get("name", "")
|
||||
old_config, _, _ = self.device_configs.get(device_name, (None, None, None))
|
||||
if old_config is not None:
|
||||
self.device_configs[device_name] = (device_config, config_status, connection_status)
|
||||
if self._current_progress is not None:
|
||||
self._current_progress += 1
|
||||
self._progress_bar.set_value(self._current_progress)
|
||||
if self._current_progress >= self._progress_bar.maximum:
|
||||
self._progress_bar.setVisible(False)
|
||||
self._progress_bar.set_value(0)
|
||||
self._current_progress = None
|
||||
self.validation_completed()
|
||||
self._update_ui()
|
||||
return
|
||||
|
||||
# Update UI sections
|
||||
self.config_section.add_device(device_config, config_status, connection_status)
|
||||
|
||||
# Recalculate summaries and UI state
|
||||
self._update_ui()
|
||||
|
||||
def validation_completed(self):
|
||||
"""Called when connection validation is completed."""
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self._update_ui()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""Test the upload redis dialog."""
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Sample device configurations for testing
|
||||
sample_configs = [
|
||||
(
|
||||
{"name": "motor_x", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_1", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_2", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_x1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_11", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_21", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.INVALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
]
|
||||
configs = {cfg[0]["name"]: cfg for cfg in sample_configs}
|
||||
apply_theme("dark")
|
||||
from unittest import mock
|
||||
|
||||
ophyd_test_widget = mock.MagicMock(spec=OphydValidation)
|
||||
dialog = UploadRedisDialog(
|
||||
parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget
|
||||
)
|
||||
dialog.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,665 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal, get_args
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
|
||||
ConfigChoiceDialog,
|
||||
DeviceFormDialog,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
UploadRedisDialog,
|
||||
)
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
DocstringView,
|
||||
OphydValidation,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
|
||||
ConnectionStatus,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
|
||||
|
||||
RPC = False
|
||||
|
||||
request_ophyd_validation = Signal(list, bool, bool)
|
||||
|
||||
def __init__(self, parent=None, client=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", *args, **kwargs)
|
||||
|
||||
# Push to Redis dialog
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTable(self)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_widget_view = QWidget(self)
|
||||
layout = QVBoxLayout(self.ophyd_widget_view)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
self.ophyd_test_view = OphydValidation(self, hide_legend=False)
|
||||
layout.addWidget(self.ophyd_test_view)
|
||||
|
||||
# Validation Results view
|
||||
self.validation_results = QTextEdit(self)
|
||||
self.validation_results.setReadOnly(True)
|
||||
self.validation_results.setPlaceholderText("Validation results will appear here...")
|
||||
layout.addWidget(self.validation_results)
|
||||
self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb)
|
||||
|
||||
for signal, slots in [
|
||||
(
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.validation_completed,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.multiple_validations_completed,
|
||||
(self.device_table_view.update_multiple_device_validations,),
|
||||
),
|
||||
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.change_device_configs,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_config_in_sync_with_redis,
|
||||
(self._update_config_enabled_button,),
|
||||
),
|
||||
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
# Build dock layout using shared helpers
|
||||
self._build_docks()
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _build_docks(self) -> None:
|
||||
# Central device table
|
||||
self.device_table_view_dock = self.new(
|
||||
self.device_table_view,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Bottom area: docstrings
|
||||
self.dm_docs_view_dock = self.new(
|
||||
self.dm_docs_view,
|
||||
where="bottom",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
# Config view left of docstrings
|
||||
self.dm_config_view_dock = self.new(
|
||||
self.dm_config_view,
|
||||
where="left",
|
||||
relative_to=self.dm_docs_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Right area: ophyd test + validation
|
||||
self.ophyd_test_dock_view = self.new(
|
||||
self.ophyd_widget_view,
|
||||
where="right",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]})
|
||||
|
||||
def _add_io_actions(self):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
save_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
|
||||
save_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("save_to_disk")
|
||||
|
||||
# Add flush config in redis
|
||||
flush_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Flush current config in BEC Server",
|
||||
label_text="Flush loaded Config",
|
||||
)
|
||||
flush_redis.action.triggered.connect(self._flush_redis_action)
|
||||
self.toolbar.components.add_safe("flush_redis", flush_redis)
|
||||
io_bundle.add_action("flush_redis")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from BEC Server",
|
||||
label_text="Get loaded Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in BEC Server",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config View",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._run_validate_connection)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _run_validate_connection(self, connect: bool = True):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = list(self.device_table_view.get_selected_device_configs())
|
||||
if not configs:
|
||||
configs = self.device_table_view.get_device_config()
|
||||
self.request_ophyd_validation.emit(configs, True, connect)
|
||||
|
||||
def _update_config_enabled_button(self, enabled: bool):
|
||||
action = self.toolbar.components.get_action("update_config_redis")
|
||||
action.action.setEnabled(not enabled)
|
||||
if enabled:
|
||||
action.action.setToolTip("Push current config to BEC Server")
|
||||
else:
|
||||
action.action.setToolTip("Current config is in sync with BEC Server, button disabled.")
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
config_path = self._get_config_base_path()
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _get_config_base_path(self) -> str:
|
||||
"""Get the base path for device configurations."""
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
return config_path
|
||||
|
||||
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
|
||||
ALLOWED_EXTS = [".yaml", ".yml"]
|
||||
filter_str = "YAML files (*.yaml *.yml);;All Files (*)"
|
||||
initial_filter = "YAML files (*.yaml *.yml);;"
|
||||
if mode == "open_file":
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
else:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Save Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
if not file_path:
|
||||
return ""
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext.lower() not in ALLOWED_EXTS:
|
||||
file_path += ".yaml"
|
||||
return file_path
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
self._open_config_choice_dialog(config)
|
||||
|
||||
def _open_config_choice_dialog(self, config: List[dict]):
|
||||
"""
|
||||
Open a dialog to choose whether to replace or add the loaded config.
|
||||
|
||||
Args:
|
||||
config (List[dict]): List of device configurations loaded from the file.
|
||||
"""
|
||||
if len(self.device_table_view.get_device_config()) == 0:
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(config)
|
||||
return
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
result = dialog.exec()
|
||||
if result == ConfigChoiceDialog.Result.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif result == ConfigChoiceDialog.Result.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
@SafeSlot()
|
||||
def _flush_redis_action(self):
|
||||
"""Action to flush the current config in Redis."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if len(self.client.device_manager.devices) == 0:
|
||||
logger.info("No devices in BEC Server, nothing to flush.")
|
||||
QMessageBox.information(
|
||||
self, "No Devices", "There is currently no config loaded on the BEC Server."
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Flush BEC Server Config",
|
||||
"Do you really want to flush the current config in BEC Server?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.set_busy(enabled=True, text="Flushing configuration in BEC Server...")
|
||||
self.client.config.reset_config()
|
||||
logger.info("Successfully flushed configuration in BEC Server.")
|
||||
self.set_busy(enabled=False)
|
||||
# Check if config is in sync, enable load redis button
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(
|
||||
self.device_table_view._is_config_in_sync_with_redis()
|
||||
)
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
for config, config_status, connnection_status in validation_results.values():
|
||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||
self.device_table_view.update_device_validation(
|
||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if not self.device_table_view.get_device_config():
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config in BEC Server",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis using the upload dialog."""
|
||||
# Check if validations are still running
|
||||
if self.ophyd_test_view.running_ophyd_tests is True:
|
||||
return QMessageBox.warning(
|
||||
self, "Validation in Progress", "Please wait for the validation to finish."
|
||||
)
|
||||
|
||||
# Get all device configurations with their validation status
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
# Create and show upload dialog
|
||||
self._upload_redis_dialog = UploadRedisDialog(
|
||||
parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view
|
||||
)
|
||||
|
||||
# Show dialog
|
||||
reply = self._upload_redis_dialog.exec_()
|
||||
|
||||
if reply == UploadRedisDialog.UploadAction.OK:
|
||||
self._push_composition_to_redis(action="set")
|
||||
elif reply == UploadRedisDialog.UploadAction.CANCEL:
|
||||
self.ophyd_test_view.cancel_all_validations()
|
||||
|
||||
def _push_composition_to_redis(self, action: ConfigAction):
|
||||
"""Push the current device composition to Redis."""
|
||||
if action not in get_args(ConfigAction):
|
||||
logger.error(f"Invalid config action: {action} for uploading to BEC Server.")
|
||||
return
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, action)
|
||||
comm.signals.done.connect(self._handle_push_complete_to_communicator)
|
||||
comm.signals.error.connect(self._handle_exception_from_communicator)
|
||||
threadpool.start(comm)
|
||||
self.set_busy(enabled=True, text="Uploading configuration to BEC Server...")
|
||||
|
||||
def _handle_push_complete_to_communicator(self):
|
||||
"""Handle completion of the config push to Redis."""
|
||||
self.set_busy(enabled=False)
|
||||
self._update_validation_icons_after_upload()
|
||||
|
||||
def _handle_exception_from_communicator(self, exception: Exception):
|
||||
"""Handle exceptions from the config communicator."""
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Error Uploading Config",
|
||||
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
|
||||
)
|
||||
self.set_busy(enabled=False)
|
||||
self._update_validation_icons_after_upload()
|
||||
|
||||
def _update_validation_icons_after_upload(self):
|
||||
"""Update validation icons after uploading config to Redis."""
|
||||
if self.client.device_manager is None:
|
||||
return
|
||||
device_names_in_session = list(self.client.device_manager.devices.keys())
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
devices_to_update = []
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
if config["name"] in device_names_in_session:
|
||||
devices_to_update.append(
|
||||
(config, config_status, ConnectionStatus.CONNECTED.value, "")
|
||||
)
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||
|
||||
# Implement the file loading logic here
|
||||
file_path = self._get_file_path(config_path, "save_file")
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
if os.path.exists(file_path):
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Overwrite File",
|
||||
f"The file '{file_path}' already exists. Do you want to overwrite it?",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
with open(file_path, "w") as file:
|
||||
file.write(yaml.dump(config))
|
||||
|
||||
# Table actions
|
||||
@SafeSlot()
|
||||
def _reset_composed_view(self):
|
||||
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _edit_device_action(self, device_config: dict):
|
||||
"""Action to edit a selected device configuration."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes")
|
||||
dialog.accepted_data.connect(self._update_device_to_table_from_dialog)
|
||||
dialog.set_device_config(device_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device")
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _update_device_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
if old_device_name and old_device_name != data.get("name", ""):
|
||||
self.device_table_view.remove_device(old_device_name)
|
||||
self.device_table_view.update_device_configs([data])
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
configs = self.device_table_view.get_selected_device_configs()
|
||||
if not configs:
|
||||
QMessageBox.warning(
|
||||
self, "No devices selected", "Please select devices from the table to remove."
|
||||
)
|
||||
return
|
||||
if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]):
|
||||
self.device_table_view.remove_device_configs(configs)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _ophyd_test_item_clicked_cb(
|
||||
self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str
|
||||
) -> None:
|
||||
self.validation_results.setMarkdown(md_msg)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerDisplayWidget()
|
||||
l.addWidget(device_manager_view)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Module for Device Manager View."""
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class DeviceManagerView(ViewBase):
|
||||
"""
|
||||
A view for users to manage devices within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.device_manager_widget = DeviceManagerWidget(parent=self)
|
||||
self.set_content(self.device_manager_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
self.device_manager_widget.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
device_manager_view = DeviceManagerView()
|
||||
_app.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=device_manager_view.device_manager_widget,
|
||||
mini_text="DM",
|
||||
)
|
||||
_app.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Top Level wrapper for device_manager widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import (
|
||||
DeviceManagerDisplayWidget,
|
||||
)
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Add device manager view
|
||||
self.device_manager_display = DeviceManagerDisplayWidget(parent=self, client=self.client)
|
||||
self.stacked_layout.addWidget(self.device_manager_display)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self._initialized = False
|
||||
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the widget becomes visible."""
|
||||
if self._initialized is False:
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||
)
|
||||
# Load current config
|
||||
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
|
||||
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_current_config.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_current_config)
|
||||
self.button_load_current_config.clicked.connect(self._load_config_clicked)
|
||||
# Load config from disk
|
||||
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
|
||||
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_config_from_file.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_config_from_file)
|
||||
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
|
||||
self._overlay_widget.setVisible(True)
|
||||
|
||||
def _load_config_from_file_clicked(self):
|
||||
"""Handle click on 'Load Config From File' button."""
|
||||
self.device_manager_display._load_file_action()
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
self.device_manager_display.device_table_view.set_device_config(config)
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager = DeviceManagerWidget()
|
||||
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||
# device_manager.device_table_view.set_device_config(config)
|
||||
layout.addWidget(device_manager)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
dark_mode_button = DarkModeButton()
|
||||
layout.addWidget(dark_mode_button)
|
||||
widget.show()
|
||||
device_manager.setWindowTitle("Device Manager View")
|
||||
device_manager.resize(1600, 1200)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,363 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
|
||||
if content is not None:
|
||||
self.set_content(content)
|
||||
|
||||
def set_content(self, content: QWidget) -> None:
|
||||
"""Replace the current content widget with a new one."""
|
||||
if self.content is not None:
|
||||
self.content.setParent(None)
|
||||
self.content = content
|
||||
self.layout().addWidget(content)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
"""Called before the view is switched away/hidden.
|
||||
|
||||
Return True to allow switching, or False to veto.
|
||||
Default implementation allows switching.
|
||||
"""
|
||||
return True
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
####################################################################################################
|
||||
|
||||
|
||||
# --- Popup UI version ---
|
||||
class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
self.waveform = Waveform(parent=self)
|
||||
self.set_content(self.waveform)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Configure Waveform View")
|
||||
|
||||
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
|
||||
|
||||
# same as in the CurveRow used in waveform
|
||||
self.device_edit = DeviceComboBox(parent=self)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
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)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
|
||||
v = QVBoxLayout(dialog)
|
||||
v.addLayout(form)
|
||||
v.addWidget(buttons)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Switch and clear?",
|
||||
"Do you want to switch views and clear the plot?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Inline stacked UI version ---
|
||||
class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Root layout for this view uses a stacked layout
|
||||
self.stack = QStackedLayout()
|
||||
container = QWidget(self)
|
||||
container.setLayout(self.stack)
|
||||
self.set_content(container)
|
||||
|
||||
# --- Page 0: Settings page (inline form)
|
||||
self.settings_page = QWidget()
|
||||
sp_layout = QVBoxLayout(self.settings_page)
|
||||
sp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
sp_layout.setSpacing(12)
|
||||
|
||||
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
|
||||
self.device_edit = DeviceComboBox(parent=self.settings_page)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
self.entry_edit = SignalComboBox(parent=self.settings_page)
|
||||
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)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(title)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK", parent=self.settings_page)
|
||||
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addWidget(ok_btn)
|
||||
|
||||
sp_layout.addLayout(form)
|
||||
sp_layout.addLayout(btn_row)
|
||||
|
||||
# --- Page 1: Waveform page
|
||||
self.waveform_page = QWidget()
|
||||
wf_layout = QVBoxLayout(self.waveform_page)
|
||||
wf_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.waveform = Waveform(parent=self.waveform_page)
|
||||
wf_layout.addWidget(self.waveform)
|
||||
|
||||
# --- Page 2: Exit confirmation page (inline)
|
||||
self.confirm_page = QWidget()
|
||||
cp_layout = QVBoxLayout(self.confirm_page)
|
||||
cp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
cp_layout.setSpacing(12)
|
||||
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
|
||||
cp_buttons = QHBoxLayout()
|
||||
no_btn = QPushButton("No", parent=self.confirm_page)
|
||||
yes_btn = QPushButton("Yes", parent=self.confirm_page)
|
||||
cp_buttons.addStretch(1)
|
||||
cp_buttons.addWidget(no_btn)
|
||||
cp_buttons.addWidget(yes_btn)
|
||||
cp_layout.addWidget(qlabel)
|
||||
cp_layout.addLayout(cp_buttons)
|
||||
|
||||
# Add pages to the stack
|
||||
self.stack.addWidget(self.settings_page) # index 0
|
||||
self.stack.addWidget(self.waveform_page) # index 1
|
||||
self.stack.addWidget(self.confirm_page) # index 2
|
||||
|
||||
# Wire settings buttons
|
||||
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
|
||||
cancel_btn.clicked.connect(self._show_waveform_without_changes)
|
||||
|
||||
# Prepare result holder for the inline confirmation
|
||||
self._exit_choice_yes = None
|
||||
yes_btn.clicked.connect(lambda: self._exit_reply(True))
|
||||
no_btn.clicked.connect(lambda: self._exit_reply(False))
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
# Always start on the settings page when entering
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
# Show inline confirmation page and synchronously wait for a choice
|
||||
# -> trick to make the choice blocking, however popup would be cleaner solution
|
||||
self._exit_choice_yes = None
|
||||
self.stack.setCurrentIndex(2)
|
||||
loop = QEventLoop()
|
||||
self._exit_loop = loop
|
||||
loop.exec_()
|
||||
|
||||
if self._exit_choice_yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
# Revert to waveform view if user cancelled switching
|
||||
self.stack.setCurrentIndex(1)
|
||||
return False
|
||||
|
||||
def _apply_settings_and_show_waveform(self):
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
# Just show waveform page without plotting
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _exit_reply(self, yes: bool):
|
||||
self._exit_choice_yes = bool(yes)
|
||||
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
|
||||
self._exit_loop.quit()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
File diff suppressed because it is too large
Load Diff
@@ -14,29 +14,24 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import GUIRegistryStateMessage
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
else:
|
||||
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
|
||||
client = lazy_import("bec_widgets.cli.client")
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
str | bool | dict,
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
@@ -54,7 +49,7 @@ def _filter_output(output: str) -> str:
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
@@ -154,10 +149,8 @@ def wait_for_server(client: BECGuiClient):
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
if client._gui_started_timer is not None:
|
||||
# cancel the timer, we are done
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
@@ -221,7 +214,6 @@ class BECGuiClient(RPCBase):
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
@@ -266,44 +258,23 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
|
||||
return self._start(wait=wait)
|
||||
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
Show the GUI window.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self._start(wait=wait)
|
||||
return self.start(wait=True)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def raise_window(self, wait: bool = True) -> None:
|
||||
"""
|
||||
Bring GUI windows to the front.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
**kwargs,
|
||||
) -> client.BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
@@ -319,11 +290,11 @@ class BECGuiClient(RPCBase):
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
"launch", "dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
@@ -392,8 +363,7 @@ class BECGuiClient(RPCBase):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
start = time.monotonic()
|
||||
while time.monotonic() < start + timeout:
|
||||
while time.time() < time.time() + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
@@ -407,9 +377,6 @@ class BECGuiClient(RPCBase):
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
if self._gui_is_alive():
|
||||
self._gui_started_event.set()
|
||||
return
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
@@ -456,8 +423,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
@@ -467,24 +434,11 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
@@ -565,7 +519,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.show(wait=True)
|
||||
gui.start(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
|
||||
@@ -40,8 +40,6 @@ class ClientGenerator:
|
||||
"""import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
@@ -53,7 +51,7 @@ from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -111,7 +109,7 @@ _Widgets = {
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
@@ -180,10 +178,7 @@ class {class_name}(RPCBase):"""
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
f"Please check the USER_ACCESS list."
|
||||
)
|
||||
if hasattr(obj, "__rpc_timeout__"):
|
||||
timeout = {"value": obj.__rpc_timeout__}
|
||||
else:
|
||||
timeout = {}
|
||||
|
||||
if isinstance(obj, (property, QtProperty)):
|
||||
# for the cli, we can map qt properties to regular properties
|
||||
if is_property_setter:
|
||||
@@ -208,26 +203,14 @@ class {class_name}(RPCBase):"""
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += f"""
|
||||
{self._rpc_call(timeout)}"""
|
||||
self.content += """
|
||||
@rpc_call"""
|
||||
self.content += f"""
|
||||
def {method}{str(sig)}:
|
||||
\"\"\"
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
||||
"""
|
||||
Decorator to mark a method as an RPC call.
|
||||
This is used to generate the client code for the method.
|
||||
"""
|
||||
if not timeout_info:
|
||||
return "@rpc_call"
|
||||
timeout = timeout_info.get("value", None)
|
||||
return f"""
|
||||
@rpc_timeout({timeout})
|
||||
@rpc_call"""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file, automatically formatted with black.
|
||||
@@ -238,18 +221,18 @@ class {class_name}(RPCBase):"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
|
||||
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
config = isort.Config(
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=False,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
@@ -332,7 +315,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
main()
|
||||
|
||||
@@ -7,7 +7,6 @@ from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.device import DeviceBaseWithConfig
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
@@ -25,43 +24,6 @@ else:
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
# if dev.<device> is passed to GUI, it passes full_name
|
||||
if hasattr(arg, "full_name"):
|
||||
return arg.full_name
|
||||
elif hasattr(arg, "name"):
|
||||
return arg.name
|
||||
return arg
|
||||
|
||||
|
||||
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
|
||||
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
|
||||
|
||||
|
||||
def rpc_timeout(timeout):
|
||||
"""
|
||||
A decorator to set a timeout for an RPC call.
|
||||
|
||||
Args:
|
||||
timeout: The timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The decorated function.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if "timeout" not in kwargs:
|
||||
kwargs["timeout"] = timeout
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
@@ -85,7 +47,15 @@ def rpc_call(func):
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
args, kwargs = _transform_args_kwargs(args, kwargs)
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
@@ -160,7 +130,6 @@ class RPCBase:
|
||||
config: dict | None = None,
|
||||
object_name: str | None = None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
@@ -175,21 +144,21 @@ class RPCBase:
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.object_name}>"
|
||||
return f"<{qualname} with name: {self.widget_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
if proxy := obj.get("container_proxy"):
|
||||
assert isinstance(proxy, str)
|
||||
self._run_rpc("remove", gui_id=proxy)
|
||||
return
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self.object_name
|
||||
|
||||
@property
|
||||
def _root(self) -> BECGuiClient:
|
||||
"""
|
||||
@@ -202,20 +171,7 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def raise_window(self):
|
||||
"""Bring this widget (or its container) to the front."""
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **kwargs) -> Any:
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -223,23 +179,15 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
gui_id = obj.get("container_proxy") # type: ignore
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
|
||||
@@ -31,9 +31,10 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = get_all_plugin_widgets() | {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
|
||||
@@ -6,12 +6,10 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import cast
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
@@ -30,6 +28,7 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
self._log_func = log_func
|
||||
self._buffer = []
|
||||
self.encoding = "utf8"
|
||||
|
||||
def write(self, buffer):
|
||||
self._buffer.append(buffer)
|
||||
@@ -40,10 +39,6 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
@@ -89,16 +84,34 @@ class GUIServer:
|
||||
service_config = ServiceConfig()
|
||||
return service_config
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
self.launcher_window = cast(LaunchWindow, self.launcher_window)
|
||||
|
||||
remaining_connections = [
|
||||
connection
|
||||
for connection in connections.values()
|
||||
if connection.parent_id != self.launcher_window.gui_id
|
||||
]
|
||||
if len(remaining_connections) <= 1:
|
||||
self.launcher_window.show()
|
||||
self.launcher_window.activateWindow()
|
||||
self.launcher_window.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
else:
|
||||
self.launcher_window.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
else:
|
||||
apply_theme("light")
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
@@ -113,6 +126,10 @@ class GUIServer:
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
register = RPCRegister()
|
||||
register.callbacks.append(self._turn_off_the_lights)
|
||||
register.broadcast()
|
||||
|
||||
if self.gui_class:
|
||||
# If the server is started with a specific gui class, we launch it.
|
||||
# This will automatically hide the launcher.
|
||||
@@ -150,8 +167,6 @@ class GUIServer:
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
92
bec_widgets/examples/general_app/general_app.py
Normal file
92
bec_widgets/examples/general_app/general_app.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECGeneralApp(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super(BECGeneralApp, self).__init__(parent)
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
|
||||
self.load_ui(ui_file_path)
|
||||
|
||||
self.resize(1280, 720)
|
||||
|
||||
self.ini_ui()
|
||||
|
||||
def ini_ui(self):
|
||||
self._setup_icons()
|
||||
self._hook_menubar_docs()
|
||||
self._hook_theme_bar()
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def _hook_menubar_docs(self):
|
||||
# BEC Docs
|
||||
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
||||
# BEC Widgets Docs
|
||||
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
|
||||
# Bug report
|
||||
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
||||
|
||||
def change_theme(self, theme):
|
||||
apply_theme(theme)
|
||||
|
||||
def _setup_icons(self):
|
||||
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
|
||||
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
|
||||
computer_icon = QIcon.fromTheme("computer")
|
||||
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
|
||||
|
||||
self.ui.action_BEC_docs.setIcon(help_icon)
|
||||
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
|
||||
self.ui.action_bug_report.setIcon(bug_icon)
|
||||
|
||||
self.ui.central_tab.setTabIcon(0, widget_icon)
|
||||
self.ui.central_tab.setTabIcon(1, computer_icon)
|
||||
|
||||
def _hook_theme_bar(self):
|
||||
self.ui.action_light.setCheckable(True)
|
||||
self.ui.action_dark.setCheckable(True)
|
||||
|
||||
# Create an action group to make sure only one can be checked at a time
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.addAction(self.ui.action_light)
|
||||
theme_group.addAction(self.ui.action_dark)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# Connect the actions to the theme change method
|
||||
|
||||
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
|
||||
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
self.ui.action_dark.trigger()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
main_window = BECGeneralApp()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
262
bec_widgets/examples/general_app/general_app.ui
Normal file
262
bec_widgets/examples/general_app/general_app.ui
Normal file
@@ -0,0 +1,262 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>1139</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="tabShape">
|
||||
<enum>QTabWidget::TabShape::Rounded</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="central_tab">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="dock_area_tab">
|
||||
<attribute name="title">
|
||||
<string>Dock Area</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECDockArea" name="dock_area"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="vscode_tab">
|
||||
<attribute name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::Computer"/>
|
||||
</attribute>
|
||||
<attribute name="title">
|
||||
<string>Visual Studio Code</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="VSCodeEditor" name="vscode"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>31</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="action_BEC_docs"/>
|
||||
<addaction name="action_BEC_widgets_docs"/>
|
||||
<addaction name="action_bug_report"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTheme">
|
||||
<property name="title">
|
||||
<string>Theme</string>
|
||||
</property>
|
||||
<addaction name="action_light"/>
|
||||
<addaction name="action_dark"/>
|
||||
</widget>
|
||||
<addaction name="menuTheme"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QDockWidget" name="dock_scan_control">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Control</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_status_2">
|
||||
<property name="windowTitle">
|
||||
<string>BEC Service Status</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_3">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box_2"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_queue">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Queue</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_4">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<row/>
|
||||
<column/>
|
||||
<column/>
|
||||
<column/>
|
||||
<item row="0" column="0"/>
|
||||
<item row="0" column="1"/>
|
||||
<item row="0" column="2"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="action_BEC_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_BEC_widgets_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Widgets Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_bug_report">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogError"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Bug Report</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_light">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Light</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_dark">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Dark</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWebEngineView</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>VSCodeEditor</class>
|
||||
<extends>WebsiteWidget</extends>
|
||||
<header>vs_code_editor</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECDockArea</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dock_area</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QWebEngineView</class>
|
||||
<extends></extends>
|
||||
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
15
bec_widgets/examples/general_app/web_links.py
Normal file
15
bec_widgets/examples/general_app/web_links.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import webbrowser
|
||||
|
||||
|
||||
class BECWebLinksMixin:
|
||||
@staticmethod
|
||||
def open_bec_docs():
|
||||
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_widgets_docs():
|
||||
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
@@ -1,23 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
@@ -25,359 +14,148 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
|
||||
Features:
|
||||
- Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`.
|
||||
- Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`.
|
||||
- Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut.
|
||||
- Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block.
|
||||
"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self._widgets_by_name: Dict[str, QWidget] = {}
|
||||
self._init_ui()
|
||||
|
||||
# expose helper API and basics in the inprocess console
|
||||
# console push
|
||||
if self.console.inprocess is True:
|
||||
# A thin API wrapper so users have a stable, minimal surface in the console
|
||||
class _ConsoleAPI:
|
||||
def __init__(self, win: "JupyterConsoleWindow"):
|
||||
self._win = win
|
||||
|
||||
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None):
|
||||
"""Add an existing QWidget as a new tab and expose it in the console under `shortcut`."""
|
||||
return self._win.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def add_widget_by_class_path(
|
||||
self,
|
||||
class_path: str,
|
||||
shortcut: str,
|
||||
kwargs: dict | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
"""Import a QWidget class from `class_path`, instantiate it, and add it."""
|
||||
return self._win.add_widget_by_class_path(
|
||||
class_path, shortcut, kwargs=kwargs, title=title
|
||||
)
|
||||
|
||||
def add_widget_by_type(
|
||||
self,
|
||||
widget_type: str,
|
||||
shortcut: str,
|
||||
kwargs: dict | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
"""Instantiate a registered BEC widget by type string and add it."""
|
||||
return self._win.add_widget_by_type(
|
||||
widget_type, shortcut, kwargs=kwargs, title=title
|
||||
)
|
||||
|
||||
def list_widgets(self):
|
||||
return list(self._win._widgets_by_name.keys())
|
||||
|
||||
def get_widget(self, shortcut: str) -> QWidget | None:
|
||||
return self._win._widgets_by_name.get(shortcut)
|
||||
|
||||
def available_widgets(self):
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
self.jc = _ConsoleAPI(self)
|
||||
self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh})
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"mi": self.mi,
|
||||
"mm": self.mm,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
"wf": self.wf,
|
||||
"scatter": self.scatter,
|
||||
"scatter_mi": self.scatter,
|
||||
"mwf": self.mwf,
|
||||
}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
|
||||
# Horizontal splitter
|
||||
splitter = QSplitter(self)
|
||||
self.layout.addWidget(splitter)
|
||||
|
||||
# Left: tabs that will host dynamically added widgets
|
||||
self.tab_widget = QTabWidget(splitter)
|
||||
tab_widget = QTabWidget(splitter)
|
||||
|
||||
# Right: console area with an add-widget mini panel on top
|
||||
right_panel = QGroupBox("Jupyter Console", splitter)
|
||||
right_layout = QVBoxLayout(right_panel)
|
||||
right_layout.setContentsMargins(6, 12, 6, 6)
|
||||
first_tab = QWidget()
|
||||
first_tab_layout = QVBoxLayout(first_tab)
|
||||
self.dock = BECDockArea(gui_id="dock")
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
# Add-widget mini panel
|
||||
add_panel = QFrame(right_panel)
|
||||
shape = QFrame.Shape.StyledPanel # PySide6 style enums
|
||||
add_panel.setFrameShape(shape)
|
||||
add_grid = QGridLayout(add_panel)
|
||||
add_grid.setContentsMargins(8, 8, 8, 8)
|
||||
add_grid.setHorizontalSpacing(8)
|
||||
add_grid.setVerticalSpacing(6)
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
instr = QLabel(
|
||||
"Add a widget by class path or choose a registered BEC widget type,"
|
||||
" and expose it in the console under a shortcut.\n"
|
||||
"Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform"
|
||||
)
|
||||
instr.setWordWrap(True)
|
||||
add_grid.addWidget(instr, 0, 0, 1, 2)
|
||||
fourth_tab = QWidget()
|
||||
fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
# Registered widget selector
|
||||
reg_label = QLabel("Registered")
|
||||
reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.registry_combo = QComboBox(add_panel)
|
||||
self.registry_combo.setEditable(False)
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
reg_row = QHBoxLayout()
|
||||
reg_row.addWidget(self.registry_combo)
|
||||
reg_row.addWidget(self.refresh_btn)
|
||||
add_grid.addWidget(reg_label, 1, 0)
|
||||
add_grid.addLayout(reg_row, 1, 1)
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
# Class path entry
|
||||
class_label = QLabel("Class")
|
||||
class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.class_path_edit = QLineEdit(add_panel)
|
||||
self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)")
|
||||
add_grid.addWidget(class_label, 2, 0)
|
||||
add_grid.addWidget(self.class_path_edit, 2, 1)
|
||||
|
||||
# Shortcut
|
||||
shortcut_label = QLabel("Shortcut")
|
||||
shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.shortcut_edit = QLineEdit(add_panel)
|
||||
self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)")
|
||||
add_grid.addWidget(shortcut_label, 3, 0)
|
||||
add_grid.addWidget(self.shortcut_edit, 3, 1)
|
||||
|
||||
# Kwargs
|
||||
kwargs_label = QLabel("Kwargs")
|
||||
kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.kwargs_edit = QLineEdit(add_panel)
|
||||
self.kwargs_edit.setPlaceholderText(
|
||||
'Optional kwargs as dict literal, e.g. {"popups": True}'
|
||||
)
|
||||
add_grid.addWidget(kwargs_label, 4, 0)
|
||||
add_grid.addWidget(self.kwargs_edit, 4, 1)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("Title")
|
||||
title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.title_edit = QLineEdit(add_panel)
|
||||
self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)")
|
||||
add_grid.addWidget(title_label, 5, 0)
|
||||
add_grid.addWidget(self.title_edit, 5, 1)
|
||||
|
||||
# Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
self.add_btn = QPushButton("Add by class path")
|
||||
self.add_btn.clicked.connect(self._on_add_widget_clicked)
|
||||
self.add_reg_btn = QPushButton("Add registered")
|
||||
self.add_reg_btn.clicked.connect(self._on_add_registered_clicked)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(self.add_reg_btn)
|
||||
btn_row.addWidget(self.add_btn)
|
||||
add_grid.addLayout(btn_row, 6, 0, 1, 2)
|
||||
|
||||
# Make the second column expand
|
||||
add_grid.setColumnStretch(0, 0)
|
||||
add_grid.setColumnStretch(1, 1)
|
||||
|
||||
# Console widget
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Vertical splitter between add panel and console
|
||||
right_splitter = QSplitter(Qt.Vertical, right_panel)
|
||||
right_splitter.addWidget(add_panel)
|
||||
right_splitter.addWidget(self.console)
|
||||
right_splitter.setStretchFactor(0, 0)
|
||||
right_splitter.setStretchFactor(1, 1)
|
||||
right_splitter.setSizes([300, 600])
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
# Put splitter into the right group box
|
||||
right_layout.addWidget(right_splitter)
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
|
||||
# Populate registry on startup
|
||||
self._populate_registry_widgets()
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
self.im = Image()
|
||||
self.mi = self.im.main_image
|
||||
sixth_tab_layout.addWidget(self.im)
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(5)
|
||||
|
||||
def _populate_registry_widgets(self):
|
||||
try:
|
||||
widget_handler.update_available_widgets()
|
||||
items = sorted(widget_handler.widget_classes.keys())
|
||||
except Exception as exc:
|
||||
print(f"Failed to load registered widgets: {exc}")
|
||||
items = []
|
||||
self.registry_combo.clear()
|
||||
self.registry_combo.addItems(items)
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.scatter = ScatterWaveform()
|
||||
self.scatter_mi = self.scatter.main_curve
|
||||
self.scatter.plot("samx", "samy", "bpm4i")
|
||||
seventh_tab_layout.addWidget(self.scatter)
|
||||
tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
tab_widget.setCurrentIndex(6)
|
||||
|
||||
def _on_add_widget_clicked(self):
|
||||
class_path = self.class_path_edit.text().strip()
|
||||
shortcut = self.shortcut_edit.text().strip()
|
||||
kwargs_text = self.kwargs_edit.text().strip()
|
||||
title = self.title_edit.text().strip() or None
|
||||
eighth_tab = QWidget()
|
||||
eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
self.mm = MotorMap()
|
||||
eighth_tab_layout.addWidget(self.mm)
|
||||
tab_widget.addTab(eighth_tab, "Motor Map")
|
||||
tab_widget.setCurrentIndex(7)
|
||||
|
||||
if not class_path or not shortcut:
|
||||
print("Please provide both class path and shortcut.")
|
||||
return
|
||||
ninth_tab = QWidget()
|
||||
ninth_tab_layout = QVBoxLayout(ninth_tab)
|
||||
self.mwf = MultiWaveform()
|
||||
ninth_tab_layout.addWidget(self.mwf)
|
||||
tab_widget.addTab(ninth_tab, "MultiWaveform")
|
||||
tab_widget.setCurrentIndex(8)
|
||||
|
||||
kwargs: dict | None = None
|
||||
if kwargs_text:
|
||||
try:
|
||||
parsed = ast.literal_eval(kwargs_text)
|
||||
if isinstance(parsed, dict):
|
||||
kwargs = parsed
|
||||
else:
|
||||
print("Kwargs must be a Python dict literal, ignoring input.")
|
||||
except Exception as exc:
|
||||
print(f"Failed to parse kwargs: {exc}")
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
try:
|
||||
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
|
||||
except Exception as exc:
|
||||
print(f"Failed to instantiate {class_path}: {exc}")
|
||||
return
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
try:
|
||||
self.add_widget(widget, shortcut, title=title)
|
||||
except Exception as exc:
|
||||
print(f"Failed to add widget: {exc}")
|
||||
return
|
||||
|
||||
# focus the newly added tab
|
||||
idx = self.tab_widget.count() - 1
|
||||
if idx >= 0:
|
||||
self.tab_widget.setCurrentIndex(idx)
|
||||
|
||||
def _on_add_registered_clicked(self):
|
||||
widget_type = self.registry_combo.currentText().strip()
|
||||
shortcut = self.shortcut_edit.text().strip()
|
||||
kwargs_text = self.kwargs_edit.text().strip()
|
||||
title = self.title_edit.text().strip() or None
|
||||
|
||||
if not widget_type or not shortcut:
|
||||
print("Please select a registered widget and provide a shortcut.")
|
||||
return
|
||||
|
||||
kwargs: dict | None = None
|
||||
if kwargs_text:
|
||||
try:
|
||||
parsed = ast.literal_eval(kwargs_text)
|
||||
if isinstance(parsed, dict):
|
||||
kwargs = parsed
|
||||
else:
|
||||
print("Kwargs must be a Python dict literal, ignoring input.")
|
||||
except Exception as exc:
|
||||
print(f"Failed to parse kwargs: {exc}")
|
||||
|
||||
try:
|
||||
self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title)
|
||||
except Exception as exc:
|
||||
print(f"Failed to add registered widget: {exc}")
|
||||
return
|
||||
|
||||
# focus the newly added tab
|
||||
idx = self.tab_widget.count() - 1
|
||||
if idx >= 0:
|
||||
self.tab_widget.setCurrentIndex(idx)
|
||||
|
||||
def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget:
|
||||
module_path, _, class_name = class_path.rpartition(".")
|
||||
if not module_path or not class_name:
|
||||
raise ValueError("class_path must be of the form 'package.module.Class'")
|
||||
module = importlib.import_module(module_path)
|
||||
cls = getattr(module, class_name)
|
||||
if kwargs is None:
|
||||
obj = cls()
|
||||
else:
|
||||
obj = cls(**kwargs)
|
||||
if not isinstance(obj, QWidget):
|
||||
raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}")
|
||||
return obj
|
||||
|
||||
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget:
|
||||
"""Add a QWidget as a new tab and expose it in the Jupyter console.
|
||||
|
||||
- widget: a QWidget instance to host in a new tab
|
||||
- shortcut: variable name used in the console to access it
|
||||
- title: optional tab title (defaults to shortcut or class name)
|
||||
"""
|
||||
if not isinstance(widget, QWidget):
|
||||
raise TypeError("widget must be a QWidget instance")
|
||||
if not shortcut or not shortcut.isidentifier():
|
||||
raise ValueError("shortcut must be a valid Python identifier")
|
||||
if shortcut in self._widgets_by_name:
|
||||
raise ValueError(f"A widget with shortcut '{shortcut}' already exists")
|
||||
if self.console.inprocess is not True:
|
||||
raise RuntimeError("Adding widgets and exposing them requires inprocess console")
|
||||
|
||||
tab_title = title or shortcut or widget.__class__.__name__
|
||||
self.tab_widget.addTab(widget, tab_title)
|
||||
self._widgets_by_name[shortcut] = widget
|
||||
|
||||
# Expose in console under the given shortcut
|
||||
self._push_to_console({shortcut: widget})
|
||||
return widget
|
||||
|
||||
def add_widget_by_class_path(
|
||||
self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
|
||||
) -> QWidget:
|
||||
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
|
||||
return self.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def add_widget_by_type(
|
||||
self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
|
||||
) -> QWidget:
|
||||
"""Instantiate a registered BEC widget by its type string and add it as a tab.
|
||||
|
||||
If kwargs does not contain `object_name`, it will default to the provided shortcut.
|
||||
"""
|
||||
# Ensure registry is loaded
|
||||
widget_handler.update_available_widgets()
|
||||
cls = widget_handler.widget_classes.get(widget_type)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown registered widget type: {widget_type}")
|
||||
|
||||
if kwargs is None:
|
||||
kwargs = {"object_name": shortcut}
|
||||
else:
|
||||
kwargs = dict(kwargs)
|
||||
kwargs.setdefault("object_name", shortcut)
|
||||
|
||||
# Instantiate and add
|
||||
widget = cls(**kwargs)
|
||||
if not isinstance(widget, QWidget):
|
||||
raise TypeError(
|
||||
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
|
||||
)
|
||||
return self.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def _push_to_console(self, mapping: Dict[str, Any]):
|
||||
"""Push Python objects into the inprocess kernel user namespace."""
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(mapping)
|
||||
else:
|
||||
raise RuntimeError("Can only push variables when using inprocess kernel")
|
||||
def _init_waveform(self):
|
||||
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
# clean up any widgets that might have custom cleanup
|
||||
try:
|
||||
# call cleanup on known containers if present
|
||||
dock = self._widgets_by_name.get("dock")
|
||||
if isinstance(dock, BECDockArea):
|
||||
dock.cleanup()
|
||||
dock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure the embedded kernel and BEC client are shut down before window teardown
|
||||
self.console.shutdown_kernel()
|
||||
self.dock.cleanup()
|
||||
self.dock.close()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
@@ -391,26 +169,18 @@ if __name__ == "__main__": # pragma: no cover
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
|
||||
# Examples: add two widgets programmatically to demonstrate usage
|
||||
try:
|
||||
win.add_widget_by_type("Waveform", shortcut="wf")
|
||||
except Exception as exc:
|
||||
print(f"Example add failed (Waveform by type): {exc}")
|
||||
|
||||
try:
|
||||
win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True})
|
||||
except Exception as exc:
|
||||
print(f"Example add failed (Image by type): {exc}")
|
||||
|
||||
win.show()
|
||||
win.resize(1500, 800)
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": {"user device"},
|
||||
"deviceTags": ["user device"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -89,28 +89,16 @@ class FakePositioner(BECPositioner):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": {"user motors"},
|
||||
"deviceTags": ["user motors"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"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
|
||||
"readback": {"kind_str": "5"}, # hinted
|
||||
"setpoint": {"kind_str": "1"}, # normal
|
||||
"velocity": {"kind_str": "2"}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -173,7 +161,7 @@ class FakePositioner(BECPositioner):
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self, cached=False):
|
||||
def read(self):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
@@ -196,8 +184,8 @@ class FakePositioner(BECPositioner):
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
@@ -212,49 +200,10 @@ class DMMock:
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
def add_devives(self, devices: list):
|
||||
for device in devices:
|
||||
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 = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
@@ -15,7 +14,8 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
@@ -77,8 +77,6 @@ class BECConnector:
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -87,21 +85,9 @@ class BECConnector:
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
root_widget: bool = False,
|
||||
parent_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
BECConnector mixin class to handle BEC client and device manager.
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration with specific gui id.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
object_name(str, optional): The object name.
|
||||
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
|
||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
@@ -113,9 +99,6 @@ class BECConnector:
|
||||
self, QObject
|
||||
), "BECConnector must be used with a QObject or any qt related class."
|
||||
|
||||
# flag to check if the object was destroyed and its cleanup was called
|
||||
self._destroyed = False
|
||||
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
@@ -125,21 +108,10 @@ class BECConnector:
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@SafeSlot()
|
||||
@pyqtSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
|
||||
try: # shutdown ophyd threads if any
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
|
||||
_dispatcher.stop()
|
||||
logger.info("Ophyd dispatcher shut down successfully.")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
logger.info("Shutting down BEC Client", repr(client))
|
||||
client.shutdown()
|
||||
|
||||
@@ -156,6 +128,7 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
self.parent_id = parent_id
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
@@ -174,6 +147,10 @@ class BECConnector:
|
||||
|
||||
# 2) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self.setParent(parent)
|
||||
if parent_id is None:
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
if connector_parent is not None:
|
||||
self.parent_id = connector_parent.gui_id
|
||||
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
@@ -182,30 +159,6 @@ class BECConnector:
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
self.root_widget = root_widget
|
||||
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
return connector_parent.gui_id if connector_parent else None
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
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:
|
||||
@@ -217,10 +170,6 @@ class BECConnector:
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
@@ -230,7 +179,7 @@ class BECConnector:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.sendPostedEvents()
|
||||
QApplication.processEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
@@ -279,7 +228,7 @@ class BECConnector:
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
@@ -407,7 +356,7 @@ class BECConnector:
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
# @SafeSlot(str)
|
||||
# @pyqtSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
@@ -439,7 +388,7 @@ class BECConnector:
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@SafeSlot(ConnectionConfig) # TODO can be also dict
|
||||
@pyqtSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
@@ -467,7 +416,6 @@ class BECConnector:
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -16,9 +16,9 @@ if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
@@ -119,7 +119,7 @@ def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
def main(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
@@ -160,11 +160,7 @@ def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer(cmd_args)
|
||||
|
||||
|
||||
def main():
|
||||
open_designer(sys.argv[1:])
|
||||
patch_designer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -4,9 +4,8 @@ import collections
|
||||
import random
|
||||
import string
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -15,52 +14,30 @@ from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb: Callable, cb_info: dict | None = None):
|
||||
"""
|
||||
Initialize the QtThreadSafeCallback.
|
||||
|
||||
Args:
|
||||
cb (Callable): The callback function to be wrapped.
|
||||
cb_info (dict, optional): Additional information about the callback. Defaults to None.
|
||||
"""
|
||||
def __init__(self, cb):
|
||||
super().__init__()
|
||||
self.cb_info = cb_info
|
||||
|
||||
self.cb = cb
|
||||
self.cb_ref = louie.saferef.safe_ref(cb)
|
||||
self.cb_signal.connect(self.cb)
|
||||
self.topics = set()
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, QtThreadSafeCallback):
|
||||
return False
|
||||
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
|
||||
return id(self.cb)
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
if self.cb_ref() is None:
|
||||
# callback has been deleted
|
||||
return
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
@@ -107,7 +84,7 @@ class BECDispatcher:
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
gui_id: str = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -120,9 +97,7 @@ class BECDispatcher:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
@@ -145,8 +120,6 @@ class BECDispatcher:
|
||||
except redis.exceptions.ConnectionError:
|
||||
logger.warning("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
register_serializer_extension()
|
||||
|
||||
logger.success("Initialized BECDispatcher")
|
||||
|
||||
self.start_cli_server(gui_id=gui_id)
|
||||
@@ -163,8 +136,7 @@ class BECDispatcher:
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
@@ -172,40 +144,34 @@ class BECDispatcher:
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
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(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
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 ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# 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:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
|
||||
if not self._registered_slots[connected_slot].topics:
|
||||
del self._registered_slots[connected_slot]
|
||||
self._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
@@ -216,16 +182,11 @@ class BECDispatcher:
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
|
||||
remove_slots = []
|
||||
for connected_slot in self._registered_slots.values():
|
||||
connected_slot.topics.difference_update(set(topics_str))
|
||||
|
||||
if not connected_slot.topics:
|
||||
remove_slots.append(connected_slot)
|
||||
|
||||
for connected_slot in remove_slots:
|
||||
self._registered_slots.pop(connected_slot, None)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECList(QListWidget):
|
||||
"""List Widget that manages ListWidgetItems with associated widgets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {}
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._widget_map
|
||||
|
||||
def add_widget_item(self, key: str, widget: QWidget):
|
||||
"""
|
||||
Add a widget to the list, mapping is associated with the given key.
|
||||
|
||||
Args:
|
||||
key (str): Key to associate with the widget.
|
||||
widget (QWidget): Widget to add to the list.
|
||||
"""
|
||||
if key in self._widget_map:
|
||||
self.remove_widget_item(key)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self.insertItem(0, item)
|
||||
self.setItemWidget(item, widget)
|
||||
self._widget_map[key] = (item, widget)
|
||||
|
||||
def remove_widget_item(self, key: str):
|
||||
"""
|
||||
Remove a widget by identifier key.
|
||||
|
||||
Args:
|
||||
key (str): Key associated with the widget to remove.
|
||||
"""
|
||||
if key not in self._widget_map:
|
||||
return
|
||||
|
||||
item, widget = self._widget_map.pop(key)
|
||||
row = self.row(item)
|
||||
self.takeItem(row)
|
||||
try:
|
||||
widget.close()
|
||||
except Exception:
|
||||
logger.debug(f"Could not close widget properly for key: {key}.")
|
||||
try:
|
||||
widget.deleteLater()
|
||||
except Exception:
|
||||
logger.debug(f"Could not delete widget properly for key: {key}.")
|
||||
|
||||
def clear_widgets(self):
|
||||
"""Remove and destroy all widget items."""
|
||||
for key in list(self._widget_map.keys()):
|
||||
self.remove_widget_item(key)
|
||||
self._widget_map.clear()
|
||||
self.clear()
|
||||
|
||||
def get_widget(self, key: str) -> QWidget | None:
|
||||
"""Return the widget for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[1] if entry else None
|
||||
|
||||
def get_item(self, key: str) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[0] if entry else None
|
||||
|
||||
def get_widgets(self) -> list[QWidget]:
|
||||
"""Return all managed widgets."""
|
||||
return [w for _, w in self._widget_map.values()]
|
||||
|
||||
def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None:
|
||||
"""Return the widget associated with a given QListWidgetItem."""
|
||||
for itm, widget in self._widget_map.values():
|
||||
if itm == item:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem associated with a given widget."""
|
||||
for itm, w in self._widget_map.values():
|
||||
if w == widget:
|
||||
return itm
|
||||
return None
|
||||
|
||||
def get_all_keys(self) -> list[str]:
|
||||
"""Return all keys for managed widgets."""
|
||||
return list(self._widget_map.keys())
|
||||
@@ -3,17 +3,12 @@ from __future__ import annotations
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
import traceback
|
||||
from importlib import util as importlib_util
|
||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||
from types import ModuleType
|
||||
from typing import Generator
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
@@ -26,7 +21,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...],
|
||||
submodule_specs: tuple[ModuleSpec | None, ...]
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
@@ -35,14 +30,7 @@ def _loaded_submodules_from_specs(
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
exception_text = "".join(traceback.format_exception(e))
|
||||
if "(most likely due to a circular import)" in exception_text:
|
||||
logger.warning(f"Circular import encountered while loading {submodule}")
|
||||
else:
|
||||
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
yield submodule
|
||||
|
||||
|
||||
@@ -53,30 +41,27 @@ def _submodule_by_name(module: ModuleType, name: str):
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their names."""
|
||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||
|
||||
classes = inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget
|
||||
and not item.__module__.startswith("bec_widgets"),
|
||||
)
|
||||
return BECClassContainer(
|
||||
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
|
||||
for k, v in classes
|
||||
return dict(
|
||||
inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
widgets = _get_widgets_from_module(module)
|
||||
if not hasattr(module, "__path__"):
|
||||
return widgets
|
||||
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
|
||||
|
||||
|
||||
@@ -90,16 +75,15 @@ def get_plugin_client_module() -> ModuleType | None:
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
return _all_widgets_from_all_submods(plugin)
|
||||
else:
|
||||
return BECClassContainer()
|
||||
return {}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
# print(get_all_plugin_widgets())
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import copier
|
||||
import typer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_repo_path
|
||||
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
|
||||
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
|
||||
|
||||
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
|
||||
|
||||
logger = bec_logger.logger
|
||||
_app = typer.Typer(rich_markup_mode="rich")
|
||||
|
||||
|
||||
def _commit_added_widget(repo: Path, name: str):
|
||||
git_stage_files(repo, [".copier-answers.yml"])
|
||||
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
|
||||
make_commit(repo, f"plugin-manager added new widget: {name}")
|
||||
logger.info(f"Committing new widget {name}")
|
||||
|
||||
|
||||
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
|
||||
return name in [w["name"] for w in widget_list]
|
||||
|
||||
|
||||
def _editor_cb(ctx: typer.Context, value: bool):
|
||||
if value and not ctx.params["use_ui"]:
|
||||
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
|
||||
return value
|
||||
|
||||
|
||||
_bold_blue = "\033[34m\033[1m"
|
||||
_off = "\033[0m"
|
||||
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
|
||||
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
|
||||
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
|
||||
Open the created widget .ui file in bec-designer now?"""
|
||||
|
||||
|
||||
@_app.command()
|
||||
def widget(
|
||||
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
|
||||
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
|
||||
open_editor: Annotated[
|
||||
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
|
||||
] = True,
|
||||
):
|
||||
"""Create a new widget plugin with the given name.
|
||||
|
||||
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
|
||||
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
|
||||
bec-designer and the compiled python version will be updated when changes are made and saved."""
|
||||
if (formatted_name := name.lower().replace("-", "_")) != name:
|
||||
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
|
||||
if not formatted_name.isidentifier():
|
||||
logger.error(
|
||||
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
|
||||
)
|
||||
exit(-1)
|
||||
logger.info(f"Adding new widget {formatted_name} to the template...")
|
||||
try:
|
||||
repo = Path(plugin_repo_path())
|
||||
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
|
||||
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
|
||||
logger.error(f"Widget {formatted_name} already exists!")
|
||||
exit(-1)
|
||||
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
|
||||
copier.run_update(
|
||||
repo,
|
||||
data=plugin_data,
|
||||
defaults=True,
|
||||
unsafe=True,
|
||||
overwrite=True,
|
||||
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
|
||||
)
|
||||
_commit_added_widget(repo, formatted_name)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("exiting...")
|
||||
exit(-1)
|
||||
logger.success(f"Added widget {formatted_name}!")
|
||||
if open_editor:
|
||||
open_and_watch_ui_editor(formatted_name)
|
||||
@@ -1,136 +0,0 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from bec_widgets.utils.bec_designer import open_designer
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class RecompileHandler(FileSystemEventHandler):
|
||||
def __init__(self, in_file: Path, out_file: Path) -> None:
|
||||
super().__init__()
|
||||
self.in_file = str(in_file)
|
||||
self.out_file = str(out_file)
|
||||
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
|
||||
self._widget_import_re = re.compile(
|
||||
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
|
||||
)
|
||||
self._widget_modules = {
|
||||
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
|
||||
}
|
||||
|
||||
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def recompile(self, event: FileSystemEvent) -> None:
|
||||
if event.src_path == self.in_file or event.dest_path == self.in_file:
|
||||
self._recompile()
|
||||
|
||||
def _recompile(self):
|
||||
logger.success(".ui file modified, recompiling...")
|
||||
code = subprocess.call(
|
||||
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
|
||||
)
|
||||
logger.success(f"compilation exited with code {code}")
|
||||
if code != 0:
|
||||
return
|
||||
self._add_comment_to_file()
|
||||
logger.success("updating imports...")
|
||||
self._update_imports()
|
||||
logger.success("formatting...")
|
||||
code = subprocess.call(
|
||||
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running black on {self.out_file}, code: {code}")
|
||||
return
|
||||
code = subprocess.call(
|
||||
[
|
||||
"isort",
|
||||
"--line-length=100",
|
||||
"--profile=black",
|
||||
"--multi-line=3",
|
||||
"--trailing-comma",
|
||||
self.out_file,
|
||||
]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
|
||||
return
|
||||
logger.success("done!")
|
||||
|
||||
def _add_comment_to_file(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
|
||||
f.write(
|
||||
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
|
||||
)
|
||||
f.write(initial)
|
||||
|
||||
def _update_imports(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
qtpy_imports = re.sub(
|
||||
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
|
||||
)
|
||||
print(self._widget_modules)
|
||||
print(re.findall(self._widget_import_re, qtpy_imports))
|
||||
widget_imports = re.sub(
|
||||
self._widget_import_re,
|
||||
lambda ob: (
|
||||
f"from {module} import {ob.group(2)}"
|
||||
if (module := self._widget_modules.get(ob.group(2))) is not None
|
||||
else ob.group(1)
|
||||
),
|
||||
qtpy_imports,
|
||||
)
|
||||
f.write(widget_imports)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def open_and_watch_ui_editor(widget_name: str):
|
||||
logger.info(f"Opening the editor for {widget_name}, and watching")
|
||||
repo = Path(plugin_repo_path())
|
||||
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
|
||||
ui_file = widget_dir / f"{widget_name}.ui"
|
||||
ui_outfile = widget_dir / f"{widget_name}_ui.py"
|
||||
|
||||
logger.info(
|
||||
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
|
||||
)
|
||||
recompile_handler = RecompileHandler(ui_file, ui_outfile)
|
||||
observer = Observer()
|
||||
observer.schedule(recompile_handler, str(ui_file.parent))
|
||||
observer.start()
|
||||
try:
|
||||
open_designer([str(ui_file)])
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
logger.info("Editing session ended, exiting...")
|
||||
@@ -1,6 +1,6 @@
|
||||
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shiboken6
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -28,7 +23,7 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove", "attach", "detach"]
|
||||
USER_ACCESS = ["remove"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -37,9 +32,8 @@ class BECWidget(BECConnector):
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
parent_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -48,7 +42,8 @@ class BECWidget(BECConnector):
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
>>> QWidget.__init__(self, parent=parent)
|
||||
|
||||
|
||||
Args:
|
||||
@@ -58,37 +53,36 @@ class BECWidget(BECConnector):
|
||||
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
|
||||
widget's apply_theme method will be called when the theme changes.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_dock=parent_dock,
|
||||
parent_id=parent_id,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
# Instead, we will set the theme to the system setting on startup
|
||||
if darkdetect.isDark():
|
||||
set_theme("dark")
|
||||
else:
|
||||
set_theme("light")
|
||||
|
||||
if theme_update:
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._loading = False
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
try:
|
||||
overlay = self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if overlay is not None:
|
||||
overlay.setGeometry(self.rect())
|
||||
overlay.raise_()
|
||||
overlay.show()
|
||||
self._loading = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Busy loader init skipped: {exc}")
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme"):
|
||||
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -97,78 +91,9 @@ class BECWidget(BECConnector):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
Returns the overlay instance or None if not a QWidget.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return None
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
return overlay
|
||||
|
||||
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the loading overlay.
|
||||
text(str, optional): The text to display on the overlay. If None, the text is not changed.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if getattr(self, "_busy_overlay", None) is None:
|
||||
self._ensure_busy_overlay(busy_text=text or "Loading…")
|
||||
if text is not None:
|
||||
self.set_busy_text(text)
|
||||
if enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
|
||||
def is_busy(self) -> bool:
|
||||
"""
|
||||
Check if the loading overlay is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if the loading overlay is enabled, False otherwise.
|
||||
"""
|
||||
return bool(getattr(self, "_loading", False))
|
||||
|
||||
def set_busy_text(self, text: str) -> None:
|
||||
"""
|
||||
Update the text on the loading overlay.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
overlay = self._ensure_busy_overlay(busy_text=text)
|
||||
if overlay is not None:
|
||||
overlay.set_text(text)
|
||||
|
||||
@SafeSlot(str)
|
||||
@Slot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
@@ -177,146 +102,16 @@ class BECWidget(BECConnector):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
def get_help_md(self) -> str:
|
||||
"""
|
||||
Method to override in subclasses to provide help text in markdown format.
|
||||
|
||||
Returns:
|
||||
str: The help text in markdown format.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
def screenshot(self, file_name: str | None = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
logger.error("Cannot take screenshot of non-QWidget instance")
|
||||
return
|
||||
|
||||
screenshot = self.grab()
|
||||
if file_name is None:
|
||||
file_name, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Screenshot",
|
||||
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
|
||||
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
|
||||
)
|
||||
if not file_name:
|
||||
return
|
||||
screenshot.save(file_name)
|
||||
logger.info(f"Screenshot saved to {file_name}")
|
||||
|
||||
def screenshot_bytes(
|
||||
self,
|
||||
*,
|
||||
max_width: int | None = None,
|
||||
max_height: int | None = None,
|
||||
fmt: str = "PNG",
|
||||
quality: int = -1,
|
||||
) -> QByteArray:
|
||||
"""
|
||||
Grab this widget, optionally scale to a max size, and return encoded image bytes.
|
||||
|
||||
If max_width/max_height are omitted (the default), capture at full resolution.
|
||||
|
||||
Args:
|
||||
max_width(int, optional): Maximum width of the screenshot.
|
||||
max_height(int, optional): Maximum height of the screenshot.
|
||||
fmt(str, optional): Image format (e.g., "PNG", "JPEG").
|
||||
quality(int, optional): Image quality (0-100), -1 for default.
|
||||
|
||||
Returns:
|
||||
QByteArray: The screenshot image bytes.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return QByteArray()
|
||||
|
||||
if not hasattr(self, "grab"):
|
||||
raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}")
|
||||
|
||||
pixmap: QPixmap = self.grab()
|
||||
if pixmap.isNull():
|
||||
return QByteArray()
|
||||
if max_width is not None or max_height is not None:
|
||||
w = max_width if max_width is not None else pixmap.width()
|
||||
h = max_height if max_height is not None else pixmap.height()
|
||||
pixmap = pixmap.scaled(
|
||||
w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation
|
||||
)
|
||||
ba = QByteArray()
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buf, fmt, quality)
|
||||
buf.close()
|
||||
return ba
|
||||
|
||||
def attach(self):
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
|
||||
if not dock.isFloating():
|
||||
return
|
||||
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
if dock.isFloating():
|
||||
return
|
||||
dock.setFloating()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
self._busy_overlay = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
if not self._destroyed:
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
"""Keeps the overlay sized and stacked over its target widget."""
|
||||
|
||||
def __init__(self, target: QWidget, overlay: QWidget):
|
||||
super().__init__(target)
|
||||
self._target = target
|
||||
self._overlay = overlay
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._target and event.type() in (
|
||||
QEvent.Resize,
|
||||
QEvent.Show,
|
||||
QEvent.LayoutRequest,
|
||||
QEvent.Move,
|
||||
):
|
||||
self._overlay.setGeometry(self._target.rect())
|
||||
self._overlay.raise_()
|
||||
return False
|
||||
|
||||
|
||||
class BusyLoaderOverlay(QWidget):
|
||||
"""
|
||||
A semi-transparent scrim with centered text and an animated spinner.
|
||||
Call show()/hide() directly, or use via `install_busy_loader(...)`.
|
||||
|
||||
Args:
|
||||
parent(QWidget): The parent widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAutoFillBackground(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self._opacity = opacity
|
||||
|
||||
self._label = QLabel(text, self)
|
||||
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(self._label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
self._label.setFont(f)
|
||||
|
||||
self._spinner = SpinnerWidget(self)
|
||||
self._spinner.setFixedSize(42, 42)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(self._label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
"""
|
||||
Update the overlay text.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
|
||||
"""
|
||||
self._opacity = max(0.0, min(1.0, float(opacity)))
|
||||
# Re-apply alpha using the current theme color
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
|
||||
def update_palette(self):
|
||||
"""
|
||||
Update colors from the current application theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme # type: ignore[attr-defined]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
# Semi-transparent scrim derived from bg
|
||||
self._scrim_color = QColor(self._bg)
|
||||
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self._spinner.update()
|
||||
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
|
||||
self._label.setStyleSheet(f"color: {fg_hex};")
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
) -> BusyLoaderOverlay:
|
||||
"""
|
||||
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
|
||||
|
||||
Args:
|
||||
target(QWidget): The widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
|
||||
)
|
||||
|
||||
self._title = QLabel("Demo Content", self)
|
||||
self._title.setAlignment(Qt.AlignCenter)
|
||||
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(self._title)
|
||||
waveform = Waveform(self)
|
||||
waveform.plot([1, 2, 3, 4, 5])
|
||||
lay.addWidget(waveform, 1)
|
||||
|
||||
QTimer.singleShot(5000, self._ready)
|
||||
|
||||
def _ready(self):
|
||||
self._title.setText("Ready ✓")
|
||||
self.set_busy(False)
|
||||
|
||||
|
||||
class DemoWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget()
|
||||
right = DemoWidget()
|
||||
|
||||
btn_on = QPushButton("Right → Loading")
|
||||
btn_off = QPushButton("Right → Ready")
|
||||
btn_text = QPushButton("Set custom text")
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
|
||||
|
||||
panel = QWidget()
|
||||
prow = QVBoxLayout(panel)
|
||||
prow.addWidget(btn_on)
|
||||
prow.addWidget(btn_off)
|
||||
prow.addWidget(btn_text)
|
||||
prow.addStretch(1)
|
||||
|
||||
central = QWidget()
|
||||
row = QHBoxLayout(central)
|
||||
row.setContentsMargins(12, 12, 12, 12)
|
||||
row.setSpacing(12)
|
||||
row.addWidget(left, 1)
|
||||
row.addWidget(right, 1)
|
||||
row.addWidget(panel, 0)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.resize(900, 420)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -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)
|
||||
@@ -1,55 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
if TYPE_CHECKING:
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
# FIXME this is legacy code, should be removed in the future
|
||||
app = QApplication.instance()
|
||||
palette = app.palette()
|
||||
return palette
|
||||
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:
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
"""
|
||||
Get the accent colors for the current theme. These colors are extensions of the color palette
|
||||
and are used to highlight specific elements in the UI.
|
||||
"""
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
accent_colors = AccentColors()
|
||||
return accent_colors
|
||||
return None
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
def process_all_deferred_deletes(qapp):
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
qapp.processEvents(QEventLoop.AllEvents)
|
||||
def _theme_update_callback():
|
||||
"""
|
||||
Internal callback function to update the theme based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# pylint: disable=protected-access
|
||||
app.theme.theme = app.os_listener._theme.lower()
|
||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
||||
apply_theme(app.os_listener._theme.lower())
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||
|
||||
app.theme_signal.theme_updated.emit(theme)
|
||||
apply_theme(theme)
|
||||
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||
app.installEventFilter(app.os_listener)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
"""
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
app = QApplication.instance()
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@@ -11,7 +11,6 @@ from qtpy.QtWidgets import (
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -123,14 +122,15 @@ class CompactPopupWidget(QWidget):
|
||||
self.compact_view_widget = QWidget(self)
|
||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
QHBoxLayout(self.compact_view_widget)
|
||||
self.compact_view_widget.layout().setSpacing(5)
|
||||
self.compact_view_widget.layout().setSpacing(0)
|
||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.compact_view_widget.layout().addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
)
|
||||
self.compact_label = QLabel(self.compact_view_widget)
|
||||
self.compact_status = LedLabel(self.compact_view_widget)
|
||||
self.compact_show_popup = QToolButton(self.compact_view_widget)
|
||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setFlat(True)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
@@ -144,7 +144,6 @@ class CompactPopupWidget(QWidget):
|
||||
self.container.setVisible(True)
|
||||
layout(self.container)
|
||||
self.layout = self.container.layout()
|
||||
self._compact_view = False
|
||||
|
||||
self.compact_show_popup.clicked.connect(self.show_popup)
|
||||
|
||||
@@ -211,7 +210,7 @@ class CompactPopupWidget(QWidget):
|
||||
|
||||
@Property(bool)
|
||||
def compact_view(self):
|
||||
return self._compact_view
|
||||
return self.compact_label.isVisible()
|
||||
|
||||
@compact_view.setter
|
||||
def compact_view(self, set_compact: bool):
|
||||
@@ -221,7 +220,6 @@ class CompactPopupWidget(QWidget):
|
||||
the full view is displayed. This is handled by toggling visibility of
|
||||
the container widget or the compact view widget.
|
||||
"""
|
||||
self._compact_view = set_compact
|
||||
if set_compact:
|
||||
self.compact_view_widget.setVisible(True)
|
||||
self.container.setVisible(False)
|
||||
@@ -261,3 +259,10 @@ class CompactPopupWidget(QWidget):
|
||||
@expand_popup.setter
|
||||
def expand_popup(self, popup: bool):
|
||||
self._expand_popup = popup
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Called by Qt, on closing - since the children widgets can be
|
||||
# BECWidgets, it is good to explicitely call 'close' on them,
|
||||
# to ensure proper resources cleanup
|
||||
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
child.close()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Type
|
||||
import itertools
|
||||
from typing import Literal, Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
@@ -72,36 +73,3 @@ class WidgetContainerUtils:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
|
||||
@staticmethod
|
||||
def name_is_protected(name: str, container: Any = None) -> bool:
|
||||
"""
|
||||
Check if the name is not protected.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
bool: True if the name is not protected, False otherwise.
|
||||
"""
|
||||
if container is None:
|
||||
container = BECGuiClient
|
||||
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
|
||||
return name in gui_client_methods
|
||||
|
||||
@staticmethod
|
||||
def raise_for_invalid_name(name: str, container: Any = None) -> None:
|
||||
"""
|
||||
Check if the name is valid. If not, raise a ValueError.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
Raises:
|
||||
ValueError: If the name is not valid.
|
||||
"""
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
|
||||
)
|
||||
if WidgetContainerUtils.name_is_protected(name, container):
|
||||
raise ValueError(f"Name '{name}' is protected. Please choose another name.")
|
||||
|
||||
@@ -5,13 +5,9 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, QPointF, Qt, Signal
|
||||
from qtpy.QtGui import QCursor, QTransform
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
|
||||
class CrosshairScatterItem(pg.ScatterPlotItem):
|
||||
def setDownsampling(self, ds=None, auto=None, method=None):
|
||||
@@ -38,21 +34,13 @@ class Crosshair(QObject):
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plot_item: pg.PlotItem,
|
||||
precision: int | None = None,
|
||||
*,
|
||||
min_precision: int = 2,
|
||||
parent=None,
|
||||
):
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
|
||||
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
parent (QObject, optional): Parent object for the QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
@@ -60,9 +48,7 @@ class Crosshair(QObject):
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self._precision = precision
|
||||
self._min_precision = max(0, int(min_precision)) # ensure non‑negative
|
||||
|
||||
self.precision = precision
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.v_line.skip_auto_range = True
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
@@ -99,64 +85,13 @@ class Crosshair(QObject):
|
||||
self.items = []
|
||||
self.marker_moved_1d = {}
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_2d_row = None
|
||||
self.marker_2d_col = None
|
||||
self.marker_2d = None
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
self.check_derivatives()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@property
|
||||
def precision(self) -> int | None:
|
||||
"""Fixed number of decimals; ``None`` enables dynamic mode."""
|
||||
return self._precision
|
||||
|
||||
@precision.setter
|
||||
def precision(self, value: int | None):
|
||||
"""
|
||||
Set the fixed number of decimals to display.
|
||||
|
||||
Args:
|
||||
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
|
||||
"""
|
||||
self._precision = value
|
||||
|
||||
@property
|
||||
def min_precision(self) -> int:
|
||||
"""Lower bound on decimals when dynamic precision is used."""
|
||||
return self._min_precision
|
||||
|
||||
@min_precision.setter
|
||||
def min_precision(self, value: int):
|
||||
"""
|
||||
Set the lower bound on decimals when dynamic precision is used.
|
||||
|
||||
Args:
|
||||
value(int): The minimum number of decimals to display. Must be non-negative.
|
||||
"""
|
||||
self._min_precision = max(0, int(value))
|
||||
|
||||
def _current_precision(self) -> int:
|
||||
"""
|
||||
Get the current precision based on the view range or fixed precision.
|
||||
"""
|
||||
if self._precision is not None:
|
||||
return self._precision
|
||||
|
||||
# Dynamically choose precision from the smaller visible span
|
||||
view_range = self.plot_item.vb.viewRange()
|
||||
x_span = abs(view_range[0][1] - view_range[0][0])
|
||||
y_span = abs(view_range[1][1] - view_range[1][0])
|
||||
|
||||
# Ignore zero spans that can appear during initialisation
|
||||
spans = [s for s in (x_span, y_span) if s > 0]
|
||||
span = min(spans) if spans else 1.0
|
||||
|
||||
exponent = np.floor(np.log10(span)) # order of magnitude
|
||||
decimals = max(0, int(-exponent) + 1)
|
||||
return max(self._min_precision, decimals)
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
@@ -164,7 +99,7 @@ class Crosshair(QObject):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
self._update_theme()
|
||||
|
||||
@SafeSlot(str)
|
||||
@Slot(str)
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -191,7 +126,7 @@ class Crosshair(QObject):
|
||||
self.coord_label.fill = pg.mkBrush(label_bg_color)
|
||||
self.coord_label.border = pg.mkPen(None)
|
||||
|
||||
@SafeSlot(int)
|
||||
@Slot(int)
|
||||
def update_highlighted_curve(self, curve_index: int):
|
||||
"""
|
||||
Update the highlighted curve in the case of multiple curves in a plot item.
|
||||
@@ -209,11 +144,8 @@ class Crosshair(QObject):
|
||||
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
|
||||
# Focus on the highlighted curve only
|
||||
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
|
||||
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
|
||||
# Handle visible items in the plot item
|
||||
self.items = self.plot_item.visible_items()
|
||||
else: # Non PlotBase case
|
||||
# Handle all items
|
||||
else:
|
||||
# Handle all curves
|
||||
self.items = self.plot_item.items
|
||||
|
||||
# Create or update markers
|
||||
@@ -263,55 +195,13 @@ class Crosshair(QObject):
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.marker_clicked_1d[name] = marker_clicked_list
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
if self.marker_2d_row is not None and self.marker_2d_col is not None:
|
||||
if self.marker_2d is not None:
|
||||
continue
|
||||
# Create horizontal ROI for row highlighting
|
||||
if item.image is None:
|
||||
continue
|
||||
self.marker_2d_row = pg.ROI(
|
||||
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d_row.skip_auto_range = True
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
self.plot_item.addItem(self.marker_2d_row)
|
||||
|
||||
# Create vertical ROI for column highlighting
|
||||
self.marker_2d_col = pg.ROI(
|
||||
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
self.marker_2d_col.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_col)
|
||||
|
||||
@SafeSlot()
|
||||
def update_markers_on_image_change(self):
|
||||
"""
|
||||
Update markers when the image changes, e.g. when the
|
||||
image shape or transformation changes.
|
||||
"""
|
||||
for item in self.items:
|
||||
if not isinstance(item, pg.ImageItem):
|
||||
continue
|
||||
if self.marker_2d_row is not None:
|
||||
self.marker_2d_row.setSize([item.image.shape[0], 1])
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
if self.marker_2d_col is not None:
|
||||
self.marker_2d_col.setSize([1, item.image.shape[1]])
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
# Get the current mouse position
|
||||
views = self.plot_item.vb.scene().views()
|
||||
if not views:
|
||||
return
|
||||
view = views[0]
|
||||
global_pos = QCursor.pos()
|
||||
view_pos = view.mapFromGlobal(global_pos)
|
||||
scene_pos = view.mapToScene(view_pos)
|
||||
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
|
||||
self.marker_2d.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
@@ -351,29 +241,11 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
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
|
||||
if image_2d is None:
|
||||
continue
|
||||
# Map scene coordinates (plot units) back to image pixel coordinates
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
xy_trans = inv_transform.map(QPointF(x, y))
|
||||
else:
|
||||
xy_trans = QPointF(x, y)
|
||||
|
||||
# Define valid pixel coordinate bounds
|
||||
min_x_px, min_y_px = 0, 0
|
||||
max_x_px = image_2d.shape[0] - 1 # columns
|
||||
max_y_px = image_2d.shape[1] - 1 # rows
|
||||
|
||||
# Clip the mapped coordinates to the image bounds
|
||||
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
|
||||
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
|
||||
|
||||
# Store snapped pixel positions
|
||||
x_values[name] = px
|
||||
y_values[name] = py
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
|
||||
if x_values and y_values:
|
||||
if all(v is None for v in x_values.values()) or all(
|
||||
@@ -413,74 +285,56 @@ class Crosshair(QObject):
|
||||
|
||||
return list_x[original_index], list_y[original_index]
|
||||
|
||||
@SafeSlot(object, tuple)
|
||||
def mouse_moved(self, event=None, manual_pos=None):
|
||||
"""
|
||||
Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event(object): The mouse moved event, which contains the scene position.
|
||||
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
|
||||
event: The mouse moved event
|
||||
"""
|
||||
# Determine target (x, y) in *plot* coordinates
|
||||
if manual_pos is not None:
|
||||
x, y = manual_pos
|
||||
else:
|
||||
if event is None:
|
||||
return # nothing to do
|
||||
scene_pos = event[0] # SignalProxy bundle
|
||||
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
x, y = view_pos.x(), view_pos.y()
|
||||
|
||||
# Update cross‑hair visuals
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
|
||||
pos = event[0]
|
||||
self.update_markers()
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
|
||||
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
|
||||
if snap_x_vals is None or snap_y_vals is None:
|
||||
return
|
||||
if all(v is None for v in snap_x_vals.values()) or all(
|
||||
v is None for v in snap_y_vals.values()
|
||||
):
|
||||
return
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
sx, sy = snap_x_vals[name], snap_y_vals[name]
|
||||
if sx is None or sy is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([sx], [sy])
|
||||
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
|
||||
self.coordinatesChanged1D.emit(
|
||||
(name, round(sx_s, precision), round(sy_s, precision))
|
||||
)
|
||||
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
px, py = snap_x_vals[name], snap_y_vals[name]
|
||||
if px is None or py is None:
|
||||
continue
|
||||
|
||||
# Respect image transforms
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(px, py, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, py])
|
||||
self.marker_2d_col.setPos([px, 0])
|
||||
|
||||
self.coordinatesChanged2D.emit((name, px, py))
|
||||
continue
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -510,7 +364,6 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -522,44 +375,21 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
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]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(x, y, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
|
||||
self.marker_2d.setPos([x, y])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
continue
|
||||
|
||||
def _get_transformed_position(
|
||||
self, x: float, y: float, transform: QTransform
|
||||
) -> tuple[QPointF, QPointF]:
|
||||
"""
|
||||
Maps the given x and y coordinates to the transformed position using the provided transform.
|
||||
Args:
|
||||
x (float): The x-coordinate to transform.
|
||||
y (float): The y-coordinate to transform.
|
||||
transform (QTransform): The transformation to apply.
|
||||
"""
|
||||
origin = transform.map(QPointF(0, 0))
|
||||
row = transform.map(QPointF(0, y)) - origin
|
||||
col = transform.map(QPointF(x, 0)) - origin
|
||||
return row, col
|
||||
|
||||
def clear_markers(self):
|
||||
"""Clears the markers from the plot."""
|
||||
for marker in self.marker_moved_1d.values():
|
||||
@@ -594,27 +424,14 @@ class Crosshair(QObject):
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
precision = self._current_precision()
|
||||
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
if image is None:
|
||||
continue
|
||||
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
pt = inv_transform.map(QPointF(x, y))
|
||||
px, py = pt.x(), pt.y()
|
||||
else:
|
||||
px, py = x, y
|
||||
|
||||
# Clip to valid pixel indices
|
||||
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
|
||||
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
|
||||
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{precision}f}"
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
@@ -632,19 +449,12 @@ class Crosshair(QObject):
|
||||
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
@SafeSlot()
|
||||
def reset(self):
|
||||
"""Resets the crosshair to its initial state."""
|
||||
if self.marker_2d_row is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_row)
|
||||
self.marker_2d_row = None
|
||||
if self.marker_2d_col is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_col)
|
||||
self.marker_2d_col = None
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
self.reset()
|
||||
if self.marker_2d is not None:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
self.marker_2d = None
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
self.clear_markers()
|
||||
|
||||
@@ -17,27 +17,13 @@ class EntryValidator:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
|
||||
# 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]
|
||||
|
||||
# edge case for if name is passed instead of full_name, should not happen
|
||||
if entry in signals_dict:
|
||||
entry = signals_dict[entry].get("obj_name", entry)
|
||||
description = device.describe()
|
||||
|
||||
if entry is None or entry == "":
|
||||
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(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. "
|
||||
f"Available signals: '{available_entries}'"
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
@@ -2,16 +2,12 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
RAISE_ERROR_DEFAULT = False
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
"""
|
||||
@@ -94,107 +90,29 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
return decorator
|
||||
|
||||
|
||||
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
|
||||
"""Internal function used by SafeConnect to handle weak references to slots."""
|
||||
instance = weak_instance()
|
||||
slot_func = weak_slot()
|
||||
|
||||
# Check if the python object has already been garbage collected
|
||||
if instance is None or slot_func is None:
|
||||
return
|
||||
|
||||
# Check if the python object has already been marked for deletion
|
||||
if getattr(instance, "_destroyed", False):
|
||||
return
|
||||
|
||||
# Check if the C++ object is still valid
|
||||
if not shiboken6.isValid(instance):
|
||||
return
|
||||
|
||||
if connect_args:
|
||||
slot_func(*connect_args)
|
||||
slot_func()
|
||||
|
||||
|
||||
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to safely handle Qt signal-slot connections. The python object is only forwarded
|
||||
as a weak reference to avoid stale objects.
|
||||
|
||||
Args:
|
||||
instance: The instance to connect.
|
||||
signal: The signal to connect to.
|
||||
slot: The slot to connect.
|
||||
|
||||
Example:
|
||||
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
"""
|
||||
weak_instance = safe_ref(instance)
|
||||
weak_slot = safe_ref(slot)
|
||||
|
||||
# Create a partial function that will check weak references before calling the actual slot
|
||||
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
|
||||
|
||||
# Connect the signal to the safe connect slot wrapper
|
||||
return signal.connect(safe_slot)
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
|
||||
otherwise error display is left to the original exception hook
|
||||
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
|
||||
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
|
||||
useful to prevent function calls from already deleted objects.
|
||||
'raise_error' keyword argument can be passed with boolean value if the error should be raised
|
||||
after the error is displayed. This is useful to propagate the error to the caller but should be used
|
||||
with great care to avoid segfaults.
|
||||
|
||||
The keywords above are stored in a container which can be overridden by passing
|
||||
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
|
||||
This is useful to override the default behavior of the decorator for a specific function call.
|
||||
|
||||
"""
|
||||
_slot_params = {
|
||||
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
|
||||
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
|
||||
"raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)),
|
||||
}
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
|
||||
def error_managed(method):
|
||||
@Slot(*slot_args, **slot_kwargs)
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
_override_slot_params = kwargs.pop("_override_slot_params", {})
|
||||
_slot_params.update(_override_slot_params)
|
||||
try:
|
||||
if not _slot_params["verify_sender"] or len(args) == 0:
|
||||
return method(*args, **kwargs)
|
||||
|
||||
_instance = args[0]
|
||||
if not isinstance(_instance, QObject):
|
||||
return method(*args, **kwargs)
|
||||
sender = _instance.sender()
|
||||
if sender is None:
|
||||
logger.info(
|
||||
f"Sender is None for {method.__module__}.{method.__qualname__}, "
|
||||
"skipping method call."
|
||||
)
|
||||
return
|
||||
return method(*args, **kwargs)
|
||||
|
||||
except Exception:
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if _slot_params["popup_error"]:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
if _slot_params["raise_error"]:
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -14,63 +12,36 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.clickable_label import ClickableLabel
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
COLLAPSED_ICON_NAME: str = "expand_all"
|
||||
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
|
||||
) -> None:
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self._title_text = f"<b>{title}</b>"
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(5, 0, 0, 0)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_icon()
|
||||
self._title = QLabel(f"<b>{title}</b>")
|
||||
self._title_layout.addWidget(self._expansion_button)
|
||||
self._title_layout.addWidget(self._title)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._contents = QWidget()
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._internal_title_layout = QHBoxLayout()
|
||||
self._title_layout.addLayout(self._internal_title_layout)
|
||||
|
||||
self._title = ClickableLabel()
|
||||
self._set_title_text(self._title_text)
|
||||
self._title_icon = ClickableLabel()
|
||||
self._internal_title_layout.addWidget(self._title_icon)
|
||||
self._internal_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._internal_title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
def get_title_layout(self) -> QHBoxLayout:
|
||||
return self._internal_title_layout
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
@@ -79,8 +50,7 @@ class ExpandableGroupFrame(QFrame):
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_expansion_icon()
|
||||
self.expansion_state_changed.emit()
|
||||
self._update_icon()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
@@ -91,9 +61,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
self.adjustSize()
|
||||
|
||||
def _update_expansion_icon(self):
|
||||
def _update_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
@@ -101,48 +70,3 @@ class ExpandableGroupFrame(QFrame):
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def icon_name(self): # type: ignore
|
||||
return self._title_icon_name
|
||||
|
||||
@icon_name.setter
|
||||
def icon_name(self, icon_name: str):
|
||||
self._title_icon_name = icon_name
|
||||
self._set_title_icon(self._title_icon_name)
|
||||
|
||||
def _set_title_icon(self, icon_name: str):
|
||||
if icon_name:
|
||||
self._title_icon.setVisible(True)
|
||||
self._title_icon.setPixmap(
|
||||
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
|
||||
)
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
@SafeProperty(str)
|
||||
def title_text(self): # type: ignore
|
||||
return self._title_text
|
||||
|
||||
@title_text.setter
|
||||
def title_text(self, title_text: str):
|
||||
self._title_text = title_text
|
||||
self._set_title_text(self._title_text)
|
||||
|
||||
def _set_title_text(self, title_text: str):
|
||||
self._title.setText(title_text)
|
||||
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -8,8 +8,6 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -17,13 +15,11 @@ class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
|
||||
@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
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,37 +34,17 @@ class WidgetFilterHandler(ABC):
|
||||
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):
|
||||
"""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
|
||||
|
||||
Args:
|
||||
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):
|
||||
completer = QCompleter(widget)
|
||||
widget.setCompleter(completer)
|
||||
@@ -88,47 +64,19 @@ class LineEditFilterHandler(WidgetFilterHandler):
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
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):
|
||||
"""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
|
||||
|
||||
Args:
|
||||
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()
|
||||
if len(selection) == 0:
|
||||
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)
|
||||
widget.addItems(selection)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""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())]
|
||||
|
||||
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:
|
||||
"""Public interface to set filters for input widgets.
|
||||
@@ -185,14 +99,13 @@ class FilterIO:
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
selection(list): List of filtered selection items.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
@@ -226,35 +139,6 @@ class FilterIO:
|
||||
)
|
||||
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
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
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
|
||||
|
||||
|
||||
class GridRow(NamedTuple):
|
||||
i: int
|
||||
label: QLabel
|
||||
widget: DynamicFormItem
|
||||
|
||||
|
||||
class TypedForm(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
value_changed = Signal()
|
||||
|
||||
RPC = True
|
||||
USER_ACCESS = ["enabled", "enabled.setter"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
items: list[tuple[str, type]] | None = None,
|
||||
form_item_specs: list[FormItemSpec] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Widget with a list of form items based on a list of types.
|
||||
|
||||
Args:
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
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:
|
||||
logger.error(
|
||||
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
|
||||
)
|
||||
items = None
|
||||
if items is None and form_item_specs is None:
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = form_item_specs or [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
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.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
gl: QGridLayout = self._form_grid.layout()
|
||||
gl.setRowStretch(gl.rowCount(), 1)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property "_model_field_name"), and the entry widget"""
|
||||
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 {
|
||||
row.label.property("_model_field_name"): row.widget.getValue()
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
gl = self._form_grid.layout()
|
||||
while w := gl.takeAt(0):
|
||||
w = w.widget()
|
||||
if hasattr(w, "teardown"):
|
||||
w.teardown()
|
||||
w.deleteLater()
|
||||
self._form_grid_container.layout().removeWidget(self._form_grid)
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
self._form_grid.adjustSize()
|
||||
self._form_grid_container.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
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):
|
||||
form_data_updated = Signal(dict)
|
||||
form_data_cleared = Signal(NoneType)
|
||||
validity_proc = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
data_model: type[BaseModel] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
data_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 = data_model
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
form_item_specs=self._form_item_specs(),
|
||||
enabled=enabled,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self): ...
|
||||
|
||||
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]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
def set_data(self, data: BaseModel):
|
||||
"""Fill the data for the form.
|
||||
|
||||
Args:
|
||||
data (BaseModel): the data to enter into the form. Must be the same type as the
|
||||
currently set schema, raises TypeError otherwise."""
|
||||
if not self._md_schema:
|
||||
raise ValueError("Schema not set - can't set data")
|
||||
if not isinstance(data, self._md_schema):
|
||||
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
|
||||
for form_item in self.enumerate_form_widgets():
|
||||
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
|
||||
|
||||
def _form_item_specs(self):
|
||||
return [
|
||||
FormItemSpec(
|
||||
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
|
||||
)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
def update_items_from_schema(self):
|
||||
self._items = self._form_item_specs()
|
||||
|
||||
def populate(self):
|
||||
self.update_items_from_schema()
|
||||
super().populate()
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._dict_from_grid()
|
||||
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on form_data_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.form_data_updated.emit(metadata_dict)
|
||||
self.validity_proc.emit(True)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
|
||||
|
||||
class PydanticModelFormItem(DynamicFormItem):
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
|
||||
) -> None:
|
||||
self._data_model = model
|
||||
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.form_data_updated.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
|
||||
self._main_widget = PydanticModelForm(data_model=self._data_model)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.get_form_data()
|
||||
|
||||
def setValue(self, value: dict):
|
||||
self._main_widget.set_data(self._data_model.model_validate(value))
|
||||
@@ -1,699 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
OrderedDict,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
get_args,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
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 (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias | Optional[Any]
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
|
||||
)
|
||||
|
||||
@field_validator("item_type", mode="before")
|
||||
@classmethod
|
||||
def _validate_type(cls, v):
|
||||
allowed_primitives = [str, int, float, bool]
|
||||
if isinstance(v, (type, UnionType)):
|
||||
return v
|
||||
if isinstance(v, GenericAlias):
|
||||
if v.__origin__ in [list, dict, 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):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
"""
|
||||
Initializes the form item widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None, optional): The parent widget. Defaults to None.
|
||||
spec (FormItemSpec): The specification for the form item.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._spec = spec
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._spec.info)
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
# Sadly, QWidget and ABC are not compatible
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._set_pretty_display()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self) -> DynamicFormItemType: ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget: QWidget
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self, *_):
|
||||
return
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
button.setVisible(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
def teardown(self):
|
||||
self._layout.deleteLater()
|
||||
self._layout.removeWidget(self._main_widget)
|
||||
self._main_widget.deleteLater()
|
||||
self._main_widget = None
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
precision = field_precision(self._spec.info)
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, float, precision)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float | Decimal):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._spec.info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class BoolToggleFormItem(BoolFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.default is PydanticUndefined:
|
||||
spec.info.default = False
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = ToggleSwitch()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
if self._default is not None:
|
||||
self._main_widget.setChecked(self._default)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_IW = TypeVar("_IW", bound=int | float | str)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
|
||||
item: type[_IW]
|
||||
widget: type[QWidget]
|
||||
default: _IW
|
||||
|
||||
|
||||
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() * 4)
|
||||
|
||||
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._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._buttons = QVBoxLayout()
|
||||
self._buttons.setContentsMargins(0, 0, 0, 0)
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
|
||||
self._add_remove_button_holder = QWidget()
|
||||
self._add_remove_button_layout = QHBoxLayout()
|
||||
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
|
||||
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setMinimumHeight(15)
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setMinimumHeight(15)
|
||||
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_remove_button_holder)
|
||||
self._add_remove_button_layout.addWidget(self._add_button)
|
||||
self._add_remove_button_layout.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 _item_height(self):
|
||||
return int(QFontMetrics(self.font()).height() * 1.5)
|
||||
|
||||
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)
|
||||
item_widget.setMinimumHeight(self._item_height())
|
||||
self._main_widget.setGridSize(QSize(0, self._item_height()))
|
||||
if (layout := item_widget.layout()) is not None:
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
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 set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._item_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):
|
||||
if self._main_widget.currentIndex() == -1:
|
||||
return None
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
return
|
||||
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)
|
||||
|
||||
|
||||
class OptionalStrLiteralFormItem(StrLiteralFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(get_args(self._spec.info.annotation)[0])
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ItemTypeFn(Protocol):
|
||||
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[
|
||||
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
|
||||
]
|
||||
|
||||
|
||||
def _is_string_literal(t: type):
|
||||
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
|
||||
|
||||
|
||||
def _is_optional_string_literal(t: type):
|
||||
if not hasattr(t, "__args__"):
|
||||
return False
|
||||
if len(t.__args__) != 2:
|
||||
return False
|
||||
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
|
||||
"optional_literal_str": (
|
||||
lambda spec: _is_optional_string_literal(spec.info.annotation),
|
||||
OptionalStrLiteralFormItem,
|
||||
),
|
||||
"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):
|
||||
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
|
||||
return widget_type
|
||||
return widget_type(spec)
|
||||
|
||||
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
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value0: set = Field(set(["a", "b"]))
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
literal: Literal["a", "b", "c"]
|
||||
nullable_literal: Literal["a", "b", "c"] | None = None
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
layout.addWidget(widg, i, 1)
|
||||
|
||||
items[6].setValue([1, 2, 3, 4])
|
||||
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
||||
|
||||
w.show()
|
||||
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())
|
||||
@@ -5,12 +5,7 @@ from typing import NamedTuple
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
|
||||
_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)
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
|
||||
|
||||
|
||||
class PluginFilenames(NamedTuple):
|
||||
@@ -27,7 +22,7 @@ class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
@@ -43,6 +38,21 @@ class DesignerPluginInfo:
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
@@ -93,20 +103,24 @@ class DesignerPluginGenerator:
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
|
||||
cls_init_found = class_re.search(init_source) is not None
|
||||
super_self_re = re.compile(
|
||||
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
super_init_found = super_self_re.search(init_source) is not None
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
# for the new style classes, we only have one super call. We can therefore check if the
|
||||
# number of __init__ calls is 2 (the class itself and the super class)
|
||||
num_inits = re.findall(r"__init__", init_source)
|
||||
if len(num_inits) == 2 and not super_init_found:
|
||||
super_init_found = 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)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
"""Module providing a guided help system for creating interactive GUI tours."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import weakref
|
||||
from typing import Callable, Dict, List, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import louie
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from louie import saferef
|
||||
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QColor, QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class TourStep(TypedDict):
|
||||
"""Type definition for a tour step."""
|
||||
|
||||
widget_ref: (
|
||||
louie.saferef.BoundMethodWeakref
|
||||
| weakref.ReferenceType[
|
||||
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
]
|
||||
| Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
| None
|
||||
)
|
||||
text: str
|
||||
title: str
|
||||
|
||||
|
||||
class TutorialOverlay(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# Keep mouse events enabled for the overlay but we'll handle them manually
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
|
||||
self.current_rect = QRect()
|
||||
self.message_box = self._create_message_box()
|
||||
self.message_box.hide()
|
||||
|
||||
def _create_message_box(self):
|
||||
box = QFrame(self)
|
||||
app = QApplication.instance()
|
||||
bg_color = app.palette().window().color()
|
||||
box.setStyleSheet(
|
||||
f"""
|
||||
QFrame {{
|
||||
background-color: {bg_color.name()};
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
layout = QVBoxLayout(box)
|
||||
|
||||
# Top layout with close button (left) and step indicator (right)
|
||||
top_layout = QHBoxLayout()
|
||||
|
||||
# Close button on the left with material icon
|
||||
self.close_btn = QPushButton()
|
||||
self.close_btn.setIcon(material_icon("close"))
|
||||
self.close_btn.setToolTip("Close")
|
||||
self.close_btn.setMaximumSize(32, 32)
|
||||
|
||||
# Step indicator on the right
|
||||
self.step_label = QLabel()
|
||||
self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;")
|
||||
|
||||
top_layout.addWidget(self.close_btn)
|
||||
top_layout.addStretch()
|
||||
top_layout.addWidget(self.step_label)
|
||||
|
||||
# Main content label
|
||||
self.label = QLabel()
|
||||
self.label.setWordWrap(True)
|
||||
|
||||
# Bottom navigation buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
# Back button with material icon
|
||||
self.back_btn = QPushButton("Back")
|
||||
self.back_btn.setIcon(material_icon("arrow_back"))
|
||||
|
||||
# Next button with material icon
|
||||
self.next_btn = QPushButton("Next")
|
||||
self.next_btn.setIcon(material_icon("arrow_forward"))
|
||||
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.back_btn)
|
||||
btn_layout.addWidget(self.next_btn)
|
||||
|
||||
layout.addLayout(top_layout)
|
||||
layout.addWidget(self.label)
|
||||
layout.addLayout(btn_layout)
|
||||
return box
|
||||
|
||||
def paintEvent(self, event): # pylint: disable=unused-argument
|
||||
if not self.current_rect.isValid():
|
||||
return
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Create semi-transparent overlay color
|
||||
overlay_color = QColor(0, 0, 0, 160)
|
||||
# Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive.
|
||||
r = self.current_rect
|
||||
rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height()
|
||||
|
||||
# Paint overlay in 4 regions around the highlighted widget using exclusive bounds
|
||||
# Top region (everything above the highlight)
|
||||
if rect_y > 0:
|
||||
top_rect = QRect(0, 0, self.width(), rect_y)
|
||||
painter.fillRect(top_rect, overlay_color)
|
||||
|
||||
# Bottom region (everything below the highlight)
|
||||
bottom_y = rect_y + rect_h
|
||||
if bottom_y < self.height():
|
||||
bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y)
|
||||
painter.fillRect(bottom_rect, overlay_color)
|
||||
|
||||
# Left region (to the left of the highlight)
|
||||
if rect_x > 0:
|
||||
left_rect = QRect(0, rect_y, rect_x, rect_h)
|
||||
painter.fillRect(left_rect, overlay_color)
|
||||
|
||||
# Right region (to the right of the highlight)
|
||||
right_x = rect_x + rect_w
|
||||
if right_x < self.width():
|
||||
right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h)
|
||||
painter.fillRect(right_rect, overlay_color)
|
||||
|
||||
# Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap.
|
||||
border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4)
|
||||
painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(border_rect, 8, 8)
|
||||
painter.end()
|
||||
|
||||
def show_step(
|
||||
self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1
|
||||
):
|
||||
"""
|
||||
rect must already be in the overlay's coordinate space (i.e. mapped).
|
||||
This method positions the message box so it does not overlap the rect.
|
||||
|
||||
Args:
|
||||
rect(QRect): rectangle to highlight
|
||||
title(str): Title text for the step
|
||||
text(str): Main content text for the step
|
||||
current_step(int): Current step number
|
||||
total_steps(int): Total number of steps in the tour
|
||||
"""
|
||||
self.current_rect = rect
|
||||
|
||||
# Update step indicator in top right
|
||||
self.step_label.setText(f"Step {current_step} of {total_steps}")
|
||||
|
||||
# Update main content text (without step number since it's now in top right)
|
||||
content_text = f"<b>{title}</b><br>{text}" if title else text
|
||||
self.label.setText(content_text)
|
||||
self.message_box.adjustSize() # ensure layout applied
|
||||
message_size = self.message_box.size() # actual widget size (width, height)
|
||||
|
||||
spacing = 15
|
||||
|
||||
# Preferred placement: to the right, vertically centered
|
||||
pos_x = rect.right() + spacing
|
||||
pos_y = rect.center().y() - (message_size.height() // 2)
|
||||
|
||||
# If it would go off the right edge, try left of the widget
|
||||
if pos_x + message_size.width() > self.width():
|
||||
pos_x = rect.left() - message_size.width() - spacing
|
||||
# vertical center is still good, but if that overlaps top/bottom we'll clamp below
|
||||
|
||||
# If it goes off the left edge (no space either side), place below, centered horizontally
|
||||
if pos_x < spacing:
|
||||
pos_x = rect.center().x() - (message_size.width() // 2)
|
||||
pos_y = rect.bottom() + spacing
|
||||
|
||||
# If it goes off the bottom, try moving it above the widget
|
||||
if pos_y + message_size.height() > self.height() - spacing:
|
||||
# if there's room above the rect, put it there
|
||||
candidate_y = rect.top() - message_size.height() - spacing
|
||||
if candidate_y >= spacing:
|
||||
pos_y = candidate_y
|
||||
else:
|
||||
# otherwise clamp to bottom with spacing
|
||||
pos_y = max(spacing, self.height() - message_size.height() - spacing)
|
||||
|
||||
# If it goes off the top, clamp down
|
||||
pos_y = max(spacing, pos_y)
|
||||
|
||||
# Make sure we don't poke the left edge
|
||||
pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing))
|
||||
|
||||
# Apply geometry and show
|
||||
self.message_box.setGeometry(
|
||||
int(pos_x), int(pos_y), message_size.width(), message_size.height()
|
||||
)
|
||||
self.message_box.show()
|
||||
self.update()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
self.setGeometry(obj.rect())
|
||||
return False
|
||||
|
||||
|
||||
class GuidedTour(QObject):
|
||||
"""
|
||||
A guided help system for creating interactive GUI tours.
|
||||
|
||||
Allows developers to register widgets with help text and create guided tours.
|
||||
"""
|
||||
|
||||
tour_started = Signal()
|
||||
tour_finished = Signal()
|
||||
step_changed = Signal(int, int) # current_step, total_steps
|
||||
|
||||
def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True):
|
||||
super().__init__()
|
||||
self._visible_check: bool = enforce_visibility
|
||||
self.main_window_ref = saferef.safe_ref(main_window)
|
||||
self.overlay = None
|
||||
self._registered_widgets: Dict[str, TourStep] = {}
|
||||
self._tour_steps: List[TourStep] = []
|
||||
self._current_index = 0
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def main_window(self) -> QWidget | None:
|
||||
"""Get the main window from weak reference."""
|
||||
if self.main_window_ref and callable(self.main_window_ref):
|
||||
widget = self.main_window_ref()
|
||||
if isinstance(widget, QWidget):
|
||||
return widget
|
||||
return None
|
||||
|
||||
def register_widget(
|
||||
self,
|
||||
*,
|
||||
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
|
||||
text: str = "",
|
||||
title: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Register a widget with help text for tours.
|
||||
|
||||
Args:
|
||||
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
|
||||
text (str): The help text for the widget. This will be shown during the tour.
|
||||
title (str, optional): A title for the widget (defaults to its class name or action text).
|
||||
|
||||
Returns:
|
||||
str: The unique ID for the registered widget.
|
||||
"""
|
||||
step_id = str(uuid4())
|
||||
# If it's a plain callable
|
||||
if callable(widget) and not hasattr(widget, "__self__"):
|
||||
widget_ref = widget
|
||||
default_title = "Widget"
|
||||
elif isinstance(widget, QAction):
|
||||
widget_ref = weakref.ref(widget)
|
||||
default_title = widget.text() or "Action"
|
||||
elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button):
|
||||
|
||||
def _resolve_toolbar_button(toolbar_action=widget):
|
||||
button = toolbar_action.get_toolbar_button()
|
||||
return (button, None)
|
||||
|
||||
widget_ref = _resolve_toolbar_button
|
||||
default_title = getattr(widget, "tooltip", "Toolbar Menu")
|
||||
else:
|
||||
widget_ref = saferef.safe_ref(widget)
|
||||
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
|
||||
|
||||
self._registered_widgets[step_id] = {
|
||||
"widget_ref": widget_ref,
|
||||
"text": text,
|
||||
"title": title or default_title,
|
||||
}
|
||||
logger.debug(f"Registered widget {title or default_title} with ID {step_id}")
|
||||
return step_id
|
||||
|
||||
def _action_highlight_rect(self, action: QAction) -> QRect | None:
|
||||
"""
|
||||
Try to find the QRect in main_window coordinates that should be highlighted for the given QAction.
|
||||
Returns None if not found (e.g. not visible).
|
||||
"""
|
||||
mw = self.main_window
|
||||
if mw is None:
|
||||
return None
|
||||
# Try toolbars first
|
||||
for tb in mw.findChildren(QToolBar):
|
||||
btn = tb.widgetForAction(action)
|
||||
if btn and btn.isVisible():
|
||||
rect = btn.rect()
|
||||
top_left = btn.mapTo(mw, rect.topLeft())
|
||||
return QRect(top_left, rect.size())
|
||||
# Try menu bars
|
||||
menubars = []
|
||||
if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)):
|
||||
mb = mw.menuBar()
|
||||
if mb and mb not in menubars:
|
||||
menubars.append(mb)
|
||||
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
|
||||
for mb in menubars:
|
||||
if action in mb.actions():
|
||||
ar = mb.actionGeometry(action)
|
||||
top_left = mb.mapTo(mw, ar.topLeft())
|
||||
return QRect(top_left, ar.size())
|
||||
return None
|
||||
|
||||
def unregister_widget(self, step_id: str) -> bool:
|
||||
"""
|
||||
Unregister a previously registered widget.
|
||||
|
||||
Args:
|
||||
step_id (str): The unique ID of the registered widget.
|
||||
|
||||
Returns:
|
||||
bool: True if the widget was unregistered, False if not found.
|
||||
"""
|
||||
if self._active:
|
||||
raise RuntimeError("Cannot unregister widget while tour is active")
|
||||
if step_id in self._registered_widgets:
|
||||
if self._registered_widgets[step_id] in self._tour_steps:
|
||||
self._tour_steps.remove(self._registered_widgets[step_id])
|
||||
del self._registered_widgets[step_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_tour(self, step_ids: List[str] | None = None) -> bool:
|
||||
"""
|
||||
Create a tour from registered widget IDs.
|
||||
|
||||
Args:
|
||||
step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included.
|
||||
|
||||
Returns:
|
||||
bool: True if the tour was created successfully, False if any step IDs were not found
|
||||
"""
|
||||
if step_ids is None:
|
||||
step_ids = list(self._registered_widgets.keys())
|
||||
|
||||
tour_steps = []
|
||||
for step_id in step_ids:
|
||||
if step_id not in self._registered_widgets:
|
||||
logger.error(f"Step ID {step_id} not found")
|
||||
return False
|
||||
tour_steps.append(self._registered_widgets[step_id])
|
||||
|
||||
self._tour_steps = tour_steps
|
||||
logger.info(f"Created tour with {len(tour_steps)} steps")
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def start_tour(self):
|
||||
"""Start the guided tour."""
|
||||
if not self._tour_steps:
|
||||
self.create_tour()
|
||||
|
||||
if self._active:
|
||||
logger.warning("Tour already active")
|
||||
return
|
||||
|
||||
main_window = self.main_window
|
||||
if main_window is None:
|
||||
logger.error("Main window no longer exists (weak reference is dead)")
|
||||
return
|
||||
|
||||
self._active = True
|
||||
self._current_index = 0
|
||||
|
||||
# Create overlay
|
||||
self.overlay = TutorialOverlay(main_window)
|
||||
self.overlay.setGeometry(main_window.rect())
|
||||
self.overlay.show()
|
||||
main_window.installEventFilter(self.overlay)
|
||||
|
||||
# Connect signals
|
||||
self.overlay.next_btn.clicked.connect(self.next_step)
|
||||
self.overlay.back_btn.clicked.connect(self.prev_step)
|
||||
self.overlay.close_btn.clicked.connect(self.stop_tour)
|
||||
|
||||
main_window.installEventFilter(self)
|
||||
self._show_current_step()
|
||||
self.tour_started.emit()
|
||||
logger.info("Started guided tour")
|
||||
|
||||
@SafeSlot()
|
||||
def stop_tour(self):
|
||||
"""Stop the current tour."""
|
||||
if not self._active:
|
||||
return
|
||||
|
||||
self._active = False
|
||||
|
||||
main_window = self.main_window
|
||||
if self.overlay and main_window:
|
||||
main_window.removeEventFilter(self.overlay)
|
||||
self.overlay.hide()
|
||||
self.overlay.deleteLater()
|
||||
self.overlay = None
|
||||
|
||||
if main_window:
|
||||
main_window.removeEventFilter(self)
|
||||
self.tour_finished.emit()
|
||||
logger.info("Stopped guided tour")
|
||||
|
||||
@SafeSlot()
|
||||
def next_step(self):
|
||||
"""Move to next step or finish tour if on last step."""
|
||||
if not self._active:
|
||||
return
|
||||
|
||||
if self._current_index < len(self._tour_steps) - 1:
|
||||
self._current_index += 1
|
||||
self._show_current_step()
|
||||
else:
|
||||
# On last step, finish the tour
|
||||
self.stop_tour()
|
||||
|
||||
@SafeSlot()
|
||||
def prev_step(self):
|
||||
"""Move to previous step."""
|
||||
if not self._active:
|
||||
return
|
||||
|
||||
if self._current_index > 0:
|
||||
self._current_index -= 1
|
||||
self._show_current_step()
|
||||
|
||||
def _show_current_step(self):
|
||||
"""Display the current step."""
|
||||
if not self._active or not self.overlay:
|
||||
return
|
||||
|
||||
step = self._tour_steps[self._current_index]
|
||||
step_title = step["title"]
|
||||
|
||||
target, step_text = self._resolve_step_target(step)
|
||||
if target is None:
|
||||
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
|
||||
return
|
||||
|
||||
main_window = self.main_window
|
||||
if main_window is None:
|
||||
logger.error("Main window no longer exists (weak reference is dead)")
|
||||
self.stop_tour()
|
||||
return
|
||||
|
||||
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
|
||||
if highlight_rect is None:
|
||||
return
|
||||
|
||||
# Calculate step numbers
|
||||
current_step = self._current_index + 1
|
||||
total_steps = len(self._tour_steps)
|
||||
|
||||
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
|
||||
|
||||
# Update button states
|
||||
self.overlay.back_btn.setEnabled(self._current_index > 0)
|
||||
|
||||
# Update next button text and state
|
||||
is_last_step = self._current_index >= len(self._tour_steps) - 1
|
||||
if is_last_step:
|
||||
self.overlay.next_btn.setText("Finish")
|
||||
self.overlay.next_btn.setIcon(material_icon("check"))
|
||||
self.overlay.next_btn.setEnabled(True)
|
||||
else:
|
||||
self.overlay.next_btn.setText("Next")
|
||||
self.overlay.next_btn.setIcon(material_icon("arrow_forward"))
|
||||
self.overlay.next_btn.setEnabled(True)
|
||||
|
||||
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
|
||||
|
||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
|
||||
"""
|
||||
Resolve the target widget/action for the given step.
|
||||
|
||||
Args:
|
||||
step(TourStep): The tour step to resolve.
|
||||
|
||||
Returns:
|
||||
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
|
||||
"""
|
||||
widget_ref = step.get("widget_ref")
|
||||
step_text = step.get("text", "")
|
||||
|
||||
if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)):
|
||||
target = widget_ref()
|
||||
else:
|
||||
target = widget_ref
|
||||
|
||||
if target is None:
|
||||
return None, step_text
|
||||
|
||||
if callable(target) and not isinstance(target, (QWidget, QAction)):
|
||||
result = target()
|
||||
if isinstance(result, tuple):
|
||||
target, alt_text = result
|
||||
if alt_text:
|
||||
step_text = alt_text
|
||||
else:
|
||||
target = result
|
||||
|
||||
return target, step_text
|
||||
|
||||
def _get_highlight_rect(
|
||||
self, main_window: QWidget, target: QWidget | QAction, step_title: str
|
||||
) -> QRect | None:
|
||||
"""
|
||||
Get the QRect in main_window coordinates to highlight for the given target.
|
||||
|
||||
Args:
|
||||
main_window(QWidget): The main window containing the target.
|
||||
target(QWidget | QAction): The target widget or action to highlight.
|
||||
step_title(str): The title of the current step (for logging purposes).
|
||||
|
||||
Returns:
|
||||
QRect | None: The rectangle to highlight, or None if not found/visible.
|
||||
"""
|
||||
if isinstance(target, QAction):
|
||||
rect = self._action_highlight_rect(target)
|
||||
if rect is None:
|
||||
self._advance_past_invalid_step(
|
||||
step_title,
|
||||
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
|
||||
)
|
||||
return None
|
||||
return rect
|
||||
|
||||
if isinstance(target, QWidget):
|
||||
if self._visible_check:
|
||||
if not target.isVisible():
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason=f"Widget {target!r} is not visible."
|
||||
)
|
||||
return None
|
||||
rect = target.rect()
|
||||
top_left = target.mapTo(main_window, rect.topLeft())
|
||||
return QRect(top_left, rect.size())
|
||||
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason=f"Unsupported step target type: {type(target)}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
|
||||
"""
|
||||
Skip the current step (or stop the tour) when the target cannot be visualised.
|
||||
"""
|
||||
logger.warning("%s Skipping step %r.", reason, step_title)
|
||||
if self._current_index < len(self._tour_steps) - 1:
|
||||
self._current_index += 1
|
||||
self._show_current_step()
|
||||
else:
|
||||
self.stop_tour()
|
||||
|
||||
def get_registered_widgets(self) -> Dict[str, TourStep]:
|
||||
"""Get all registered widgets."""
|
||||
return self._registered_widgets.copy()
|
||||
|
||||
def clear_registrations(self):
|
||||
"""Clear all registered widgets."""
|
||||
if self._active:
|
||||
self.stop_tour()
|
||||
self._registered_widgets.clear()
|
||||
self._tour_steps.clear()
|
||||
logger.info("Cleared all registrations")
|
||||
|
||||
def set_visibility_enforcement(self, enabled: bool):
|
||||
"""Enable or disable visibility checks when highlighting widgets."""
|
||||
self._visible_check = enabled
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle window resize/move events to update step positioning."""
|
||||
if event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
|
||||
if self._active:
|
||||
self._show_current_step()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
|
||||
################################################################################
|
||||
############ # Example usage of GuidedTour system ##############################
|
||||
################################################################################
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Guided Tour Demo")
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support."))
|
||||
self.btn1 = QPushButton("Primary Button")
|
||||
self.btn2 = QPushButton("Secondary Button")
|
||||
self.status_label = QLabel("Use the controls below or the toolbar to interact.")
|
||||
self.start_tour_btn = QPushButton("Start Guided Tour")
|
||||
|
||||
layout.addWidget(self.btn1)
|
||||
layout.addWidget(self.btn2)
|
||||
layout.addWidget(self.status_label)
|
||||
layout.addStretch()
|
||||
layout.addWidget(self.start_tour_btn)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Guided tour system
|
||||
self.guided_help = GuidedTour(self)
|
||||
|
||||
# Menus for demonstrating QAction support in menu bars
|
||||
self._init_menu_bar()
|
||||
|
||||
# Modular toolbar showcasing QAction targets
|
||||
self._init_toolbar()
|
||||
|
||||
# Register widgets and actions with help text
|
||||
primary_step = self.guided_help.register_widget(
|
||||
widget=self.btn1,
|
||||
text="The primary button updates the status text when clicked.",
|
||||
title="Primary Button",
|
||||
)
|
||||
secondary_step = self.guided_help.register_widget(
|
||||
widget=self.btn2,
|
||||
text="The secondary button complements the demo layout.",
|
||||
title="Secondary Button",
|
||||
)
|
||||
toolbar_action_step = self.guided_help.register_widget(
|
||||
widget=self.toolbar_tour_action.action,
|
||||
text="Toolbar actions are supported in the guided tour. This one also starts the tour.",
|
||||
title="Toolbar Tour Action",
|
||||
)
|
||||
tools_menu_step = self.guided_help.register_widget(
|
||||
widget=self.toolbar.components.get_action("menu_tools"),
|
||||
text="Expandable toolbar menus group related actions. This button opens the tools menu.",
|
||||
title="Tools Menu",
|
||||
)
|
||||
|
||||
# Create tour from registered widgets
|
||||
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
|
||||
widget_ids = self.tour_step_ids
|
||||
self.guided_help.create_tour(widget_ids)
|
||||
|
||||
# Connect start button
|
||||
self.start_tour_btn.clicked.connect(self.guided_help.start_tour)
|
||||
|
||||
def _init_menu_bar(self):
|
||||
menu_bar = self.menuBar()
|
||||
info_menu = menu_bar.addMenu("Info")
|
||||
info_menu.setObjectName("info-menu")
|
||||
self.info_menu = info_menu
|
||||
self.info_menu_action = info_menu.menuAction()
|
||||
self.about_action = info_menu.addAction("About This Demo")
|
||||
|
||||
def _init_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
|
||||
self.toolbar_tour_action = MaterialIconAction(
|
||||
"play_circle", tooltip="Start the guided tour", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action)
|
||||
|
||||
self.toolbar_highlight_action = MaterialIconAction(
|
||||
"visibility", tooltip="Highlight the primary button", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action)
|
||||
|
||||
demo_bundle = self.toolbar.new_bundle("demo")
|
||||
demo_bundle.add_action("tour-start")
|
||||
demo_bundle.add_action("inspect-primary")
|
||||
|
||||
self._setup_tools_menu()
|
||||
self.toolbar.show_bundles(["demo", "menu_tools"])
|
||||
self.toolbar.refresh()
|
||||
|
||||
self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour)
|
||||
|
||||
def _setup_tools_menu(self):
|
||||
self.tools_menu_actions: dict[str, MaterialIconAction] = {
|
||||
"notes": MaterialIconAction(
|
||||
icon_name="note_add", tooltip="Add a note", filled=True, parent=self
|
||||
),
|
||||
"bookmark": MaterialIconAction(
|
||||
icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self
|
||||
),
|
||||
"settings": MaterialIconAction(
|
||||
icon_name="tune", tooltip="Adjust settings", filled=True, parent=self
|
||||
),
|
||||
}
|
||||
self.tools_menu_action = ExpandableMenuAction(
|
||||
label="Tools ", actions=self.tools_menu_actions
|
||||
)
|
||||
self.toolbar.components.add_safe("menu_tools", self.tools_menu_action)
|
||||
bundle = ToolbarBundle("menu_tools", self.toolbar.components)
|
||||
bundle.add_action("menu_tools")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
apply_theme("dark")
|
||||
w = MainWindow()
|
||||
w.resize(400, 300)
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Module providing a simple help inspector tool for QtWidgets."""
|
||||
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import AccentColors, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
A help inspector widget that allows to inspect other widgets in the application.
|
||||
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
|
||||
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
|
||||
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
|
||||
The inspector also allows to register custom callback that are called with the inspected widget
|
||||
as argument. This may be useful in the future to hook up more callbacks with custom signals.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): The parent widget of the help inspector.
|
||||
client: Optional client for BECWidget functionality.
|
||||
size (tuple[int, int]): Optional size of the icon for the help inspector.
|
||||
"""
|
||||
|
||||
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
|
||||
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
|
||||
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
layout = QtWidgets.QHBoxLayout(self) # type: ignore
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._active = False
|
||||
self._init_ui()
|
||||
self._callbacks = {}
|
||||
# Register the default callbacks
|
||||
self._register_default_callbacks()
|
||||
# Connect the button toggle signal
|
||||
self._button.toggled.connect(self._toggle_mode)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Init the UI components."""
|
||||
colors: AccentColors = get_accent_colors()
|
||||
self._button = QtWidgets.QToolButton(self.parent())
|
||||
self._button.setCheckable(True)
|
||||
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
self._button.setText("Help Inspect Tool")
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setToolTip("Click to enter Help Mode")
|
||||
self.layout().addWidget(self._button)
|
||||
|
||||
def apply_theme(self, theme: str) -> None:
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
if self._active:
|
||||
self._button.setIcon(self._icon_checked())
|
||||
else:
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _toggle_mode(self, enabled: bool):
|
||||
"""
|
||||
Toggle the help inspection mode.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable or disable the help inspection mode.
|
||||
"""
|
||||
if self._app is None:
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
self._active = enabled
|
||||
if enabled:
|
||||
self._app.installEventFilter(self)
|
||||
self._button.setIcon(self._icon_checked())
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
|
||||
else:
|
||||
self._app.removeEventFilter(self)
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setChecked(False)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
|
||||
"""
|
||||
Filter events to capture Key_Escape event, and mouse clicks
|
||||
if event filter is active. Any click event on a widget is suppressed, if
|
||||
the Inspector is active, and the registered callbacks are called with
|
||||
the clicked widget as argument.
|
||||
|
||||
Args:
|
||||
obj (QObject): The object that received the event.
|
||||
event (QEvent): The event to filter.
|
||||
"""
|
||||
# If not active, return immediately
|
||||
if not self._active:
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, handle escape key
|
||||
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
|
||||
self._toggle_mode(False)
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, and left mouse button pressed, handle click
|
||||
if event.type() == QtCore.QEvent.MouseButtonPress:
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
widget = self._app.widgetAt(event.globalPos())
|
||||
if widget is None:
|
||||
return super().eventFilter(obj, event)
|
||||
# Get BECWidget ancestor
|
||||
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
||||
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
||||
if not isinstance(widget, BECWidget):
|
||||
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if widget:
|
||||
if widget is self:
|
||||
self._toggle_mode(False)
|
||||
return True
|
||||
for cb in self._callbacks.values():
|
||||
try:
|
||||
cb(widget)
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred in callback {cb}: {e}")
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
|
||||
"""
|
||||
Register a callback to be called when a widget is inspected.
|
||||
The callback should be callable with the following signature:
|
||||
callback(widget: QWidget) -> None
|
||||
|
||||
Args:
|
||||
callback (Callable[[QWidget], None]): The callback function to register.
|
||||
Returns:
|
||||
str: A unique ID for the registered callback.
|
||||
"""
|
||||
cb_id = str(uuid4())
|
||||
self._callbacks[cb_id] = callback
|
||||
return cb_id
|
||||
|
||||
def unregister_callback(self, cb_id: str):
|
||||
"""Unregister a previously registered callback."""
|
||||
self._callbacks.pop(cb_id, None)
|
||||
|
||||
def _register_default_callbacks(self):
|
||||
"""Default behavior: publish tooltip, docstring, bec_help"""
|
||||
|
||||
def cb_doc(widget: QtWidgets.QWidget):
|
||||
docstring = widget.__doc__ or "No documentation available."
|
||||
self.widget_docstring.emit(docstring)
|
||||
|
||||
def cb_help(widget: QtWidgets.QWidget):
|
||||
tooltip = widget.toolTip() or "No tooltip available."
|
||||
self.widget_tooltip.emit(tooltip)
|
||||
|
||||
def cb_bec_help(widget: QtWidgets.QWidget):
|
||||
help_text = None
|
||||
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
|
||||
try:
|
||||
help_text = widget.get_help_md()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error retrieving help text from {widget}: {e}")
|
||||
if help_text is None:
|
||||
help_text = widget.toolTip() or "No help available."
|
||||
if not isinstance(help_text, str):
|
||||
logger.error(
|
||||
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
|
||||
)
|
||||
help_text = str(help_text)
|
||||
self.bec_widget_help.emit(help_text)
|
||||
|
||||
self.register_callback(cb_doc)
|
||||
self.register_callback(cb_help)
|
||||
self.register_callback(cb_bec_help)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
apply_theme("dark")
|
||||
main_window.setWindowTitle("Help Inspector Test")
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
dark_mode_button = DarkModeButton(parent=main_window)
|
||||
main_layout.addWidget(dark_mode_button)
|
||||
|
||||
help_inspector = HelpInspector()
|
||||
main_layout.addWidget(help_inspector)
|
||||
|
||||
test_button = QtWidgets.QPushButton("Test Button")
|
||||
test_button.setToolTip("This is a test button.")
|
||||
test_line_edit = QtWidgets.QLineEdit()
|
||||
test_line_edit.setToolTip("This is a test line edit.")
|
||||
test_label = QtWidgets.QLabel("Test Label")
|
||||
test_label.setToolTip("")
|
||||
box = PositionerBox()
|
||||
|
||||
layout_1 = QtWidgets.QHBoxLayout()
|
||||
layout_1.addWidget(test_button)
|
||||
layout_1.addWidget(test_line_edit)
|
||||
layout_1.addWidget(test_label)
|
||||
layout_1.addWidget(box)
|
||||
main_layout.addLayout(layout_1)
|
||||
|
||||
doc_label = QtWidgets.QLabel("Docstring will appear here.")
|
||||
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
|
||||
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
|
||||
main_layout.addWidget(doc_label)
|
||||
main_layout.addWidget(tool_tip_label)
|
||||
main_layout.addWidget(bec_help_label)
|
||||
|
||||
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
|
||||
help_inspector.widget_docstring.connect(doc_label.setText)
|
||||
help_inspector.bec_widget_help.connect(bec_help_label.setText)
|
||||
|
||||
main_window.setCentralWidget(central_widget)
|
||||
main_window.resize(400, 200)
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots."""
|
||||
|
||||
from __future__ import annotations
|
||||
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots. """
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import re
|
||||
from functools import partial
|
||||
from re import Pattern
|
||||
from typing import Generic, Iterable, NamedTuple, TypeVar
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.control.device_manager.components._util import (
|
||||
SORT_KEY_ROLE,
|
||||
SortableQListWidgetItem,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
|
||||
|
||||
|
||||
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
|
||||
def __init__(
|
||||
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
|
||||
self.item_tuple = _Items
|
||||
self._item_class = item_class
|
||||
self._item_dict: dict[str, _Items] = {}
|
||||
|
||||
def __contains__(self, id: str):
|
||||
return id in self._item_dict
|
||||
|
||||
def clear(self) -> None:
|
||||
self._item_dict = {}
|
||||
return super().clear()
|
||||
|
||||
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
|
||||
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
|
||||
|
||||
Args:
|
||||
id (str): the key under which to store the list item in the internal dict
|
||||
|
||||
Returns:
|
||||
The widget created in the addition process
|
||||
"""
|
||||
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.takeItem(self.row(item))
|
||||
del self._item_dict[id]
|
||||
self.sortItems()
|
||||
|
||||
def _updatesize(item: QListWidgetItem, item_widget: _EF):
|
||||
item_widget.adjustSize()
|
||||
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
|
||||
|
||||
item = SortableQListWidgetItem(self)
|
||||
item.setData(SORT_KEY_ROLE, id) # used for sorting
|
||||
|
||||
item_widget = self._item_class(*args, **kwargs)
|
||||
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
|
||||
item_widget.imminent_deletion.connect(partial(_remove_item, item))
|
||||
item_widget.broadcast_size_hint.connect(item.setSizeHint)
|
||||
|
||||
self.addItem(item)
|
||||
self.setItemWidget(item, item_widget)
|
||||
self._item_dict[id] = self.item_tuple(item, item_widget)
|
||||
|
||||
item.setSizeHint(item_widget.sizeHint())
|
||||
return (item, item_widget)
|
||||
|
||||
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
|
||||
items = [self.takeItem(0) for i in range(self.count())]
|
||||
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
|
||||
|
||||
for it in items:
|
||||
self.addItem(it)
|
||||
# reattach its custom widget
|
||||
widget = self.itemWidget(it)
|
||||
if widget:
|
||||
self.setItemWidget(it, widget)
|
||||
|
||||
def item_widget_pairs(self):
|
||||
return self._item_dict.values()
|
||||
|
||||
def widgets(self):
|
||||
return (i.widget for i in self._item_dict.values())
|
||||
|
||||
def get_item_widget(self, id: str):
|
||||
if (item := self._item_dict.get(id)) is None:
|
||||
return None
|
||||
return item
|
||||
|
||||
def set_hidden_pattern(self, pattern: Pattern):
|
||||
self.hide_all()
|
||||
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
|
||||
|
||||
def set_hidden(self, ids: Iterable[str]):
|
||||
self._set_hidden(ids, True)
|
||||
|
||||
def _set_hidden(self, ids: Iterable[str], hidden: bool):
|
||||
for id in ids:
|
||||
if (_item := self._item_dict.get(id)) is not None:
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
else:
|
||||
logger.warning(
|
||||
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
|
||||
)
|
||||
self.sortItems()
|
||||
|
||||
def hide_all(self):
|
||||
self.set_hidden_state_on_all(True)
|
||||
|
||||
def unhide_all(self):
|
||||
self.set_hidden_state_on_all(False)
|
||||
|
||||
def set_hidden_state_on_all(self, hidden: bool):
|
||||
for _item in self._item_dict.values():
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
self.sortItems()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_filter(self, value: str):
|
||||
if value == "":
|
||||
return self.unhide_all()
|
||||
try:
|
||||
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
|
||||
except Exception:
|
||||
self.unhide_all()
|
||||
@@ -1,16 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
@@ -2,7 +2,6 @@
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
{widget_import}
|
||||
@@ -21,8 +20,6 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import importlib
|
||||
import inspect
|
||||
import os
|
||||
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 qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
@@ -90,15 +90,15 @@ class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type[BECWidget]
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_plugin: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self, initial: Iterable[BECClassInfo] = []):
|
||||
self._collection: list[BECClassInfo] = list(initial)
|
||||
def __init__(self):
|
||||
self._collection: list[BECClassInfo] = []
|
||||
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
@@ -106,16 +106,6 @@ class BECClassContainer:
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def __add__(self, other: BECClassContainer):
|
||||
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
|
||||
|
||||
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
|
||||
"""get a dict of {name: Type} for all the entries in the collection.
|
||||
|
||||
Args:
|
||||
ignores(list[str]): a list of class names to exclude from the dictionary."""
|
||||
return {c.name: c.obj for c in self if c.name not in ignores}
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
@@ -125,44 +115,53 @@ class BECClassContainer:
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
"""Return a list of class names"""
|
||||
return [c.name for c in self]
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""Get the collection of classes."""
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""Get all connector classes."""
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""Get all top-level classes."""
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""Get all plugins. These are all classes that are on the top level and are widgets."""
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""Get all widgets. These are all classes inheriting from BECWidget."""
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
"""Get all classes."""
|
||||
"""
|
||||
Get all classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
@@ -198,10 +197,10 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
if issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
|
||||
@@ -1,694 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFontDialog,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class PropertyEditor(QWidget):
|
||||
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
|
||||
super().__init__(parent)
|
||||
self._target = target
|
||||
self._bec_only = show_only_bec
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Name row
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Name:"))
|
||||
self.name_edit = QLineEdit(target.objectName())
|
||||
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# BEC only checkbox
|
||||
filter_row = QHBoxLayout()
|
||||
self.chk_show_qt = QCheckBox("Show Qt properties")
|
||||
self.chk_show_qt.setChecked(False)
|
||||
filter_row.addWidget(self.chk_show_qt)
|
||||
filter_row.addStretch(1)
|
||||
layout.addLayout(filter_row)
|
||||
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
|
||||
|
||||
# Main tree widget
|
||||
self.tree = QTreeWidget(self)
|
||||
self.tree.setColumnCount(2)
|
||||
self.tree.setHeaderLabels(["Property", "Value"])
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setRootIsDecorated(False)
|
||||
layout.addWidget(self.tree)
|
||||
self._build()
|
||||
|
||||
def _class_chain(self):
|
||||
chain = []
|
||||
mo = self._target.metaObject()
|
||||
while mo is not None:
|
||||
chain.append(mo)
|
||||
mo = mo.superClass()
|
||||
return chain
|
||||
|
||||
def set_show_only_bec(self, flag: bool):
|
||||
self._bec_only = flag
|
||||
self._build()
|
||||
|
||||
def _set_equal_columns(self):
|
||||
header = self.tree.header()
|
||||
header.setSectionResizeMode(0, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(1, QHeaderView.Interactive)
|
||||
w = self.tree.viewport().width() or self.tree.width()
|
||||
if w > 0:
|
||||
half = max(1, w // 2)
|
||||
self.tree.setColumnWidth(0, half)
|
||||
self.tree.setColumnWidth(1, w - half)
|
||||
|
||||
def _build(self):
|
||||
self.tree.clear()
|
||||
for mo in self._class_chain():
|
||||
class_name = mo.className()
|
||||
if self._bec_only and not self._is_bec_metaobject(mo):
|
||||
continue
|
||||
group_item = QTreeWidgetItem(self.tree, [class_name])
|
||||
group_item.setFirstColumnSpanned(True)
|
||||
start = mo.propertyOffset()
|
||||
end = mo.propertyCount()
|
||||
for i in range(start, end):
|
||||
prop = mo.property(i)
|
||||
if (
|
||||
not prop.isReadable()
|
||||
or not prop.isWritable()
|
||||
or not prop.isStored()
|
||||
or not prop.isDesignable()
|
||||
):
|
||||
continue
|
||||
name = prop.name()
|
||||
if name == "objectName":
|
||||
continue
|
||||
value = self._target.property(name)
|
||||
self._add_property_row(group_item, name, value, prop)
|
||||
if group_item.childCount() == 0:
|
||||
idx = self.tree.indexOfTopLevelItem(group_item)
|
||||
self.tree.takeTopLevelItem(idx)
|
||||
self.tree.expandAll()
|
||||
QTimer.singleShot(0, self._set_equal_columns)
|
||||
|
||||
def _enum_int(self, obj) -> int:
|
||||
return int(getattr(obj, "value", obj))
|
||||
|
||||
def _make_sizepolicy_editor(self, name: str, sp):
|
||||
if not isinstance(sp, QSizePolicy):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
h_combo = QComboBox(wrap)
|
||||
v_combo = QComboBox(wrap)
|
||||
hs = QSpinBox(wrap)
|
||||
vs = QSpinBox(wrap)
|
||||
for b in (hs, vs):
|
||||
b.setRange(0, 16777215)
|
||||
policies = [
|
||||
(QSizePolicy.Fixed, "Fixed"),
|
||||
(QSizePolicy.Minimum, "Minimum"),
|
||||
(QSizePolicy.Maximum, "Maximum"),
|
||||
(QSizePolicy.Preferred, "Preferred"),
|
||||
(QSizePolicy.Expanding, "Expanding"),
|
||||
(QSizePolicy.MinimumExpanding, "MinExpanding"),
|
||||
(QSizePolicy.Ignored, "Ignored"),
|
||||
]
|
||||
for pol, text in policies:
|
||||
h_combo.addItem(text, self._enum_int(pol))
|
||||
v_combo.addItem(text, self._enum_int(pol))
|
||||
|
||||
def _set_current(combo, val):
|
||||
idx = combo.findData(self._enum_int(val))
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
_set_current(h_combo, sp.horizontalPolicy())
|
||||
_set_current(v_combo, sp.verticalPolicy())
|
||||
hs.setValue(sp.horizontalStretch())
|
||||
vs.setValue(sp.verticalStretch())
|
||||
|
||||
def apply_changes():
|
||||
hp = QSizePolicy.Policy(h_combo.currentData())
|
||||
vp = QSizePolicy.Policy(v_combo.currentData())
|
||||
nsp = QSizePolicy(hp, vp)
|
||||
nsp.setHorizontalStretch(hs.value())
|
||||
nsp.setVerticalStretch(vs.value())
|
||||
self._target.setProperty(name, nsp)
|
||||
|
||||
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
hs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
vs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
row.addWidget(h_combo)
|
||||
row.addWidget(v_combo)
|
||||
row.addWidget(hs)
|
||||
row.addWidget(vs)
|
||||
return wrap
|
||||
|
||||
def _make_locale_editor(self, name: str, loc):
|
||||
if not isinstance(loc, QLocale):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
lang_combo = QComboBox(wrap)
|
||||
country_combo = QComboBox(wrap)
|
||||
for lang in QLocale.Language:
|
||||
try:
|
||||
lang_int = self._enum_int(lang)
|
||||
except Exception:
|
||||
continue
|
||||
if lang_int < 0:
|
||||
continue
|
||||
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
|
||||
lang_combo.addItem(name_txt, lang_int)
|
||||
|
||||
def populate_countries():
|
||||
country_combo.blockSignals(True)
|
||||
country_combo.clear()
|
||||
for terr in QLocale.Country:
|
||||
try:
|
||||
terr_int = self._enum_int(terr)
|
||||
except Exception:
|
||||
continue
|
||||
if terr_int < 0:
|
||||
continue
|
||||
text = QLocale.countryToString(QLocale.Country(terr_int))
|
||||
country_combo.addItem(text, terr_int)
|
||||
cur_country = self._enum_int(loc.country())
|
||||
idx = country_combo.findData(cur_country)
|
||||
if idx >= 0:
|
||||
country_combo.setCurrentIndex(idx)
|
||||
country_combo.blockSignals(False)
|
||||
|
||||
cur_lang = self._enum_int(loc.language())
|
||||
idx = lang_combo.findData(cur_lang)
|
||||
if idx >= 0:
|
||||
lang_combo.setCurrentIndex(idx)
|
||||
populate_countries()
|
||||
|
||||
def apply_locale():
|
||||
lang = QLocale.Language(int(lang_combo.currentData()))
|
||||
country = QLocale.Country(int(country_combo.currentData()))
|
||||
self._target.setProperty(name, QLocale(lang, country))
|
||||
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
row.addWidget(lang_combo)
|
||||
row.addWidget(country_combo)
|
||||
return wrap
|
||||
|
||||
def _make_icon_editor(self, name: str, icon):
|
||||
btn = QPushButton(self)
|
||||
btn.setText("Choose…")
|
||||
if isinstance(icon, QIcon) and not icon.isNull():
|
||||
btn.setIcon(icon)
|
||||
|
||||
def pick():
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
|
||||
)
|
||||
if path:
|
||||
ic = QIcon(path)
|
||||
self._target.setProperty(name, ic)
|
||||
btn.setIcon(ic)
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _spin_pair(self, ints: bool = True):
|
||||
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
if ints:
|
||||
box1.setRange(-10_000_000, 10_000_000)
|
||||
box2.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in (box1, box2):
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
row.addWidget(box1)
|
||||
row.addWidget(box2)
|
||||
return wrap, box1, box2
|
||||
|
||||
def _spin_quad(self, ints: bool = True):
|
||||
s = QSpinBox if ints else QDoubleSpinBox
|
||||
boxes = [s(self) for _ in range(4)]
|
||||
if ints:
|
||||
for b in boxes:
|
||||
b.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in boxes:
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
for b in boxes:
|
||||
row.addWidget(b)
|
||||
return wrap, boxes
|
||||
|
||||
def _make_font_editor(self, name: str, value):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(value, QFont):
|
||||
btn.setText(f"{value.family()}, {value.pointSize()}pt")
|
||||
else:
|
||||
btn.setText("Select font…")
|
||||
|
||||
def pick():
|
||||
ok, font = QFontDialog.getFont(
|
||||
value if isinstance(value, QFont) else QFont(), self, "Select Font"
|
||||
)
|
||||
if ok:
|
||||
self._target.setProperty(name, font)
|
||||
btn.setText(f"{font.family()}, {font.pointSize()}pt")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _make_color_editor(self, initial: QColor, apply_cb):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(initial, QColor):
|
||||
btn.setText(initial.name())
|
||||
btn.setStyleSheet(f"background:{initial.name()};")
|
||||
else:
|
||||
btn.setText("Select color…")
|
||||
|
||||
def pick():
|
||||
col = QColorDialog.getColor(
|
||||
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
|
||||
)
|
||||
if col.isValid():
|
||||
apply_cb(col)
|
||||
btn.setText(col.name())
|
||||
btn.setStyleSheet(f"background:{col.name()};")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _apply_palette_color(
|
||||
self,
|
||||
name: str,
|
||||
pal: QPalette,
|
||||
group: QPalette.ColorGroup,
|
||||
role: QPalette.ColorRole,
|
||||
col: QColor,
|
||||
):
|
||||
pal.setColor(group, role, col)
|
||||
self._target.setProperty(name, pal)
|
||||
|
||||
def _make_palette_editor(self, name: str, pal: QPalette):
|
||||
if not isinstance(pal, QPalette):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
group_combo = QComboBox(wrap)
|
||||
role_combo = QComboBox(wrap)
|
||||
pick_btn = self._make_color_editor(
|
||||
pal.color(QPalette.Active, QPalette.WindowText),
|
||||
lambda col: self._apply_palette_color(
|
||||
name, pal, QPalette.Active, QPalette.WindowText, col
|
||||
),
|
||||
)
|
||||
groups = [
|
||||
(QPalette.Active, "Active"),
|
||||
(QPalette.Inactive, "Inactive"),
|
||||
(QPalette.Disabled, "Disabled"),
|
||||
]
|
||||
for g, label in groups:
|
||||
group_combo.addItem(label, int(getattr(g, "value", g)))
|
||||
roles = [
|
||||
(QPalette.WindowText, "WindowText"),
|
||||
(QPalette.Window, "Window"),
|
||||
(QPalette.Base, "Base"),
|
||||
(QPalette.AlternateBase, "AlternateBase"),
|
||||
(QPalette.ToolTipBase, "ToolTipBase"),
|
||||
(QPalette.ToolTipText, "ToolTipText"),
|
||||
(QPalette.Text, "Text"),
|
||||
(QPalette.Button, "Button"),
|
||||
(QPalette.ButtonText, "ButtonText"),
|
||||
(QPalette.BrightText, "BrightText"),
|
||||
(QPalette.Highlight, "Highlight"),
|
||||
(QPalette.HighlightedText, "HighlightedText"),
|
||||
]
|
||||
for r, label in roles:
|
||||
role_combo.addItem(label, int(getattr(r, "value", r)))
|
||||
|
||||
def rewire_button():
|
||||
g = QPalette.ColorGroup(int(group_combo.currentData()))
|
||||
r = QPalette.ColorRole(int(role_combo.currentData()))
|
||||
col = pal.color(g, r)
|
||||
while row.count() > 2:
|
||||
w = row.takeAt(2).widget()
|
||||
if w:
|
||||
w.deleteLater()
|
||||
btn = self._make_color_editor(
|
||||
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
|
||||
)
|
||||
row.addWidget(btn)
|
||||
|
||||
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
row.addWidget(group_combo)
|
||||
row.addWidget(role_combo)
|
||||
row.addWidget(pick_btn)
|
||||
return wrap
|
||||
|
||||
def _make_cursor_editor(self, name: str, value):
|
||||
combo = QComboBox(self)
|
||||
shapes = [
|
||||
(Qt.ArrowCursor, "Arrow"),
|
||||
(Qt.IBeamCursor, "IBeam"),
|
||||
(Qt.WaitCursor, "Wait"),
|
||||
(Qt.CrossCursor, "Cross"),
|
||||
(Qt.UpArrowCursor, "UpArrow"),
|
||||
(Qt.SizeAllCursor, "SizeAll"),
|
||||
(Qt.PointingHandCursor, "PointingHand"),
|
||||
(Qt.ForbiddenCursor, "Forbidden"),
|
||||
(Qt.WhatsThisCursor, "WhatsThis"),
|
||||
(Qt.BusyCursor, "Busy"),
|
||||
]
|
||||
current_shape = None
|
||||
if isinstance(value, QCursor):
|
||||
try:
|
||||
enum_val = value.shape()
|
||||
current_shape = int(getattr(enum_val, "value", enum_val))
|
||||
except Exception:
|
||||
current_shape = None
|
||||
for shape, text in shapes:
|
||||
combo.addItem(text, int(getattr(shape, "value", shape)))
|
||||
if current_shape is not None:
|
||||
idx = combo.findData(current_shape)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
def apply_index(i):
|
||||
shape_val = int(combo.itemData(i))
|
||||
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
|
||||
item = QTreeWidgetItem(parent, [name, ""])
|
||||
editor = self._make_editor(name, value, prop)
|
||||
if editor is not None:
|
||||
self.tree.setItemWidget(item, 1, editor)
|
||||
else:
|
||||
item.setText(1, repr(value))
|
||||
|
||||
def _is_bec_metaobject(self, mo) -> bool:
|
||||
cname = mo.className()
|
||||
for cls in type(self._target).mro():
|
||||
if getattr(cls, "__name__", None) == cname:
|
||||
mod = getattr(cls, "__module__", "")
|
||||
return mod.startswith("bec_widgets")
|
||||
return False
|
||||
|
||||
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
|
||||
if not meta_enum.isFlag():
|
||||
key = meta_enum.valueToKey(value_int)
|
||||
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
|
||||
parts = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
k = meta_enum.key(i)
|
||||
v = meta_enum.value(i)
|
||||
if value_int & v:
|
||||
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
|
||||
parts.append(k)
|
||||
return " | ".join(parts) if parts else "0"
|
||||
|
||||
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
pass
|
||||
v = getattr(value, "value", None)
|
||||
if isinstance(v, (int,)):
|
||||
return int(v)
|
||||
n = getattr(value, "name", None)
|
||||
if isinstance(n, str):
|
||||
res = meta_enum.keyToValue(n)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
s = str(value)
|
||||
parts = [p.strip() for p in s.replace(",", "|").split("|")]
|
||||
keys = []
|
||||
for p in parts:
|
||||
if "." in p:
|
||||
p = p.split(".")[-1]
|
||||
keys.append(p)
|
||||
keystr = "|".join(keys)
|
||||
try:
|
||||
res = meta_enum.keysToValue(keystr)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _make_enum_editor(self, name: str, value, prop):
|
||||
meta_enum = prop.enumerator()
|
||||
current = self._enum_value_to_int(meta_enum, value)
|
||||
|
||||
if not meta_enum.isFlag():
|
||||
combo = QComboBox(self)
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
combo.addItem(key, meta_enum.value(i))
|
||||
idx = combo.findData(current)
|
||||
if idx < 0:
|
||||
txt = self._enum_text(meta_enum, current)
|
||||
idx = combo.findText(txt)
|
||||
combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def apply_index(i):
|
||||
v = combo.itemData(i)
|
||||
self._target.setProperty(name, int(v))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
btn = QToolButton(self)
|
||||
btn.setText(self._enum_text(meta_enum, current))
|
||||
btn.setPopupMode(QToolButton.InstantPopup)
|
||||
menu = QMenu(btn)
|
||||
actions = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
act = menu.addAction(key)
|
||||
act.setCheckable(True)
|
||||
act.setChecked(bool(current & meta_enum.value(i)))
|
||||
actions.append(act)
|
||||
btn.setMenu(menu)
|
||||
|
||||
def apply_flags():
|
||||
flags = 0
|
||||
for i, act in enumerate(actions):
|
||||
if act.isChecked():
|
||||
flags |= meta_enum.value(i)
|
||||
self._target.setProperty(name, int(flags))
|
||||
btn.setText(self._enum_text(meta_enum, flags))
|
||||
|
||||
menu.triggered.connect(lambda _a: apply_flags())
|
||||
return btn
|
||||
|
||||
def _make_editor(self, name: str, value, prop):
|
||||
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
|
||||
|
||||
if prop.isEnumType():
|
||||
return self._make_enum_editor(name, value, prop)
|
||||
if isinstance(value, QColor):
|
||||
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
|
||||
if isinstance(value, QFont):
|
||||
return self._make_font_editor(name, value)
|
||||
if isinstance(value, QPalette):
|
||||
return self._make_palette_editor(name, value)
|
||||
if isinstance(value, QCursor):
|
||||
return self._make_cursor_editor(name, value)
|
||||
if isinstance(value, QSizePolicy):
|
||||
ed = self._make_sizepolicy_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QLocale):
|
||||
ed = self._make_locale_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QIcon):
|
||||
ed = self._make_icon_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QSize):
|
||||
wrap, w, h = self._spin_pair(ints=True)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QSizeF):
|
||||
wrap, w, h = self._spin_pair(ints=False)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPoint):
|
||||
wrap, x, y = self._spin_pair(ints=True)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPointF):
|
||||
wrap, x, y = self._spin_pair(ints=False)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QRect):
|
||||
wrap, boxes = self._spin_quad(ints=True)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rect():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rect())
|
||||
return wrap
|
||||
if isinstance(value, QRectF):
|
||||
wrap, boxes = self._spin_quad(ints=False)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rectf():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rectf())
|
||||
return wrap
|
||||
if isinstance(value, bool):
|
||||
w = QCheckBox(self)
|
||||
w.setChecked(bool(value))
|
||||
w.toggled.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
w = QSpinBox(self)
|
||||
w.setRange(-10_000_000, 10_000_000)
|
||||
w.setValue(int(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, float):
|
||||
w = QDoubleSpinBox(self)
|
||||
w.setDecimals(6)
|
||||
w.setRange(-1e12, 1e12)
|
||||
w.setSingleStep(0.1)
|
||||
w.setValue(float(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, str):
|
||||
w = QLineEdit(self)
|
||||
w.setText(value)
|
||||
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
class DemoApp(QWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
|
||||
# Create a BECWidget instance example
|
||||
waveform = self.create_waveform()
|
||||
|
||||
# property editor for the BECWidget
|
||||
property_editor = PropertyEditor(waveform, show_only_bec=True)
|
||||
|
||||
layout.addWidget(waveform)
|
||||
layout.addWidget(property_editor)
|
||||
|
||||
def create_waveform(self):
|
||||
"""Create a new waveform widget."""
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
waveform = Waveform(parent=self)
|
||||
waveform.title = "New Waveform"
|
||||
waveform.x_label = "X Axis"
|
||||
waveform.y_label = "Y Axis"
|
||||
return waveform
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover:
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
demo = DemoApp()
|
||||
demo.setWindowTitle("Property Editor Demo")
|
||||
demo.resize(1200, 800)
|
||||
demo.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,12 +1,11 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property, Qt
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(QFrame):
|
||||
# TODO this should be removed completely in favor of QSS styling, no time now
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
@@ -29,9 +28,6 @@ class RoundedFrame(QFrame):
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Ensure QSS can paint background/border on this widget
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
# Create a layout for the frame
|
||||
if orientation == "vertical":
|
||||
self.layout = QVBoxLayout(self)
|
||||
@@ -49,10 +45,22 @@ class RoundedFrame(QFrame):
|
||||
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
self.update_style()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if self.content_widget is not None and isinstance(
|
||||
self.content_widget, pg.GraphicsLayoutWidget
|
||||
):
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
else:
|
||||
self.background_color = "#141414" # Dark mode
|
||||
|
||||
self.update_style()
|
||||
|
||||
@Property(int)
|
||||
@@ -69,21 +77,34 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
if self.background_color:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
border-radius: {self._radius}px;
|
||||
background-color: {self.background_color};
|
||||
border-radius: {self._radius}; /* Rounded corners */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
"""
|
||||
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
|
||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
||||
|
||||
Args:
|
||||
border (str): Border style (e.g., 'none', '1px solid red').
|
||||
"""
|
||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||
self.content_widget.setStyleSheet("")
|
||||
# Apply border style via stylesheet
|
||||
self.content_widget.setStyleSheet(
|
||||
f"""
|
||||
GraphicsLayoutWidget {{
|
||||
border: {border}; /* Explicitly set the border */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover
|
||||
@@ -107,14 +128,24 @@ class ExampleApp(QWidget): # pragma: no cover
|
||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(dark_button)
|
||||
layout.addWidget(plot1)
|
||||
layout.addWidget(plot2)
|
||||
layout.addWidget(rounded_plot1)
|
||||
layout.addWidget(rounded_plot2)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Theme flip demo removed; global theming applies automatically
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
def change_theme():
|
||||
rounded_plot1.apply_theme("light")
|
||||
rounded_plot2.apply_theme("dark")
|
||||
|
||||
QTimer.singleShot(100, change_theme)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -13,17 +13,3 @@ def register_rpc_methods(cls):
|
||||
if getattr(method, "rpc_public", False):
|
||||
cls.USER_ACCESS.add(name)
|
||||
return cls
|
||||
|
||||
|
||||
def rpc_timeout(timeout: float | None):
|
||||
"""
|
||||
Decorator to set a timeout for RPC methods.
|
||||
The actual implementation of timeout handling is within the cli module. This decorator
|
||||
is solely to inform the generate-cli command about the timeout value.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
func.__rpc_timeout__ = timeout # Store the timeout value in the function
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -11,7 +10,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -19,9 +18,8 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from qtpy.QtCore import QObject
|
||||
else:
|
||||
@@ -85,7 +83,6 @@ class RPCServer:
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
self._registry_update_callbacks = []
|
||||
self._broadcasted_data = {}
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
@@ -129,44 +126,16 @@ class RPCServer:
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
@@ -220,16 +189,12 @@ class RPCServer:
|
||||
if not getattr(val, "RPC", True):
|
||||
continue
|
||||
data[key] = self._serialize_bec_connector(val)
|
||||
if self._broadcasted_data == data:
|
||||
return
|
||||
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(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1,
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
|
||||
@@ -247,20 +212,9 @@ class RPCServer:
|
||||
config_dict = connector.config.model_dump()
|
||||
config_dict["parent_id"] = getattr(connector, "parent_id", None)
|
||||
|
||||
try:
|
||||
parent = connector.parent()
|
||||
if isinstance(parent, BECMainWindow):
|
||||
container_proxy = parent.gui_id
|
||||
else:
|
||||
container_proxy = None
|
||||
except Exception:
|
||||
container_proxy = None
|
||||
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
@@ -271,7 +225,6 @@ class RPCServer:
|
||||
"object_name": connector.object_name or connector.__class__.__name__,
|
||||
"widget_class": widget_class,
|
||||
"config": config_dict,
|
||||
"container_proxy": container_proxy,
|
||||
"__rpc__": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
from bec_lib.codecs import BECCodec
|
||||
from bec_lib.serialization import msgpack
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
|
||||
class QPointFEncoder(BECCodec):
|
||||
obj_type = QPointF
|
||||
|
||||
@staticmethod
|
||||
def encode(obj: QPointF) -> list[float]:
|
||||
"""Encode a QPointF object to a list of floats."""
|
||||
return [obj.x(), obj.y()]
|
||||
|
||||
@staticmethod
|
||||
def decode(type_name: str, data: list[float]) -> list[float]:
|
||||
"""No-op function since QPointF is encoded as a list of floats."""
|
||||
return data
|
||||
|
||||
|
||||
def register_serializer_extension():
|
||||
"""
|
||||
Register the serializer extension for the BECConnector.
|
||||
"""
|
||||
if not msgpack.is_registered(QPointF):
|
||||
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
|
||||
@@ -1,5 +1,5 @@
|
||||
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 bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user