mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 02:30:54 +02:00
Compare commits
53 Commits
v3.2.4
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
| d91a61ae45 | |||
| 0419b63e3f | |||
| b4ad75aade | |||
| 82ce27a700 | |||
| fe8e6d9427 | |||
| f8cd8d0d06 | |||
| 821b61bcc0 | |||
| d9afe31d61 | |||
| 8df2576390 | |||
| 240c6dd439 | |||
| 6039d070b7 | |||
| 04fc10213d | |||
| c3d6eb009f | |||
| 669a84cb21 | |||
| 1211a66577 | |||
| a2374f00b0 | |||
| 92dc947e68 | |||
| 574fd051c1 | |||
| 8070d60370 | |||
| 23a232da9c | |||
| c7d40ca82c | |||
| 1c38d7a6ff | |||
| 75afac2fc7 | |||
| 4ad8b7cb22 | |||
| 8c9d06e9d6 | |||
| 781f7cc055 | |||
| 2dac1c38c1 | |||
| a00bb0fe58 | |||
| 435873b539 | |||
| cb253e5998 | |||
| 17fa18d9d2 | |||
| f7210b88ea | |||
| cb2ccb02ff | |||
| cacf98cb9a | |||
| 5d0ec2186b | |||
| fc4ad051f8 | |||
| d5aaba1adb | |||
| 5b9fdc7d30 | |||
| 943a911a17 | |||
| 80f7829adc | |||
| f6f0ad445f | |||
| 554704a63a | |||
| a3e044bf50 | |||
| 66ae2b25fd | |||
| 532b7422b8 | |||
| 80d1c29ab1 | |||
| 18b0cd4142 | |||
| a26e1f4811 | |||
| 3b7bc2b25a | |||
| a7cf98cb58 | |||
| 01b317367a | |||
| a9a4d3aa6e | |||
| 605c13a6ea |
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
import functools
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from github import Github
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GHConfig(BaseModel):
|
||||
token: str
|
||||
organization: str
|
||||
repository: str
|
||||
project_number: int
|
||||
graphql_url: str
|
||||
rest_url: str
|
||||
headers: dict
|
||||
|
||||
|
||||
class ProjectItemHandler:
|
||||
"""
|
||||
A class to handle GitHub project items.
|
||||
"""
|
||||
|
||||
def __init__(self, gh_config: GHConfig):
|
||||
self.gh_config = gh_config
|
||||
self.gh = Github(gh_config.token)
|
||||
self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}")
|
||||
self.project_node_id = self.get_project_node_id()
|
||||
|
||||
def set_issue_status(
|
||||
self,
|
||||
status: Literal[
|
||||
"Selected for Development",
|
||||
"Weekly Backlog",
|
||||
"In Development",
|
||||
"Ready For Review",
|
||||
"On Hold",
|
||||
"Done",
|
||||
],
|
||||
issue_number: int | None = None,
|
||||
issue_node_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Set the status field of a GitHub issue in the project.
|
||||
|
||||
Args:
|
||||
status (str): The status to set. Must be one of the predefined statuses.
|
||||
issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided.
|
||||
issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided.
|
||||
"""
|
||||
if not issue_number and not issue_node_id:
|
||||
raise ValueError("Either issue_number or issue_node_id must be provided.")
|
||||
if issue_number and issue_node_id:
|
||||
raise ValueError("Only one of issue_number or issue_node_id must be provided.")
|
||||
if issue_number is not None:
|
||||
issue = self.repo.get_issue(issue_number)
|
||||
issue_id = self.get_issue_info(issue.node_id)[0]["id"]
|
||||
else:
|
||||
issue_id = issue_node_id
|
||||
field_id, option_id = self.get_status_field_id(field_name=status)
|
||||
self.set_field_option(issue_id, field_id, option_id)
|
||||
|
||||
def run_graphql(self, query: str, variables: dict) -> dict:
|
||||
"""
|
||||
Execute a GraphQL query against the GitHub API.
|
||||
|
||||
Args:
|
||||
query (str): The GraphQL query to execute.
|
||||
variables (dict): The variables to pass to the query.
|
||||
|
||||
Returns:
|
||||
dict: The response from the GitHub API.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.gh_config.graphql_url,
|
||||
json={"query": query, "variables": variables},
|
||||
headers=self.gh_config.headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
f"Query failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def get_project_node_id(self):
|
||||
"""
|
||||
Retrieve the project node ID from the GitHub API.
|
||||
"""
|
||||
query = """
|
||||
query($owner: String!, $number: Int!) {
|
||||
organization(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return resp["data"]["organization"]["projectV2"]["id"]
|
||||
|
||||
def get_issue_info(self, issue_node_id: str):
|
||||
"""
|
||||
Get the project-related information for a given issue node ID.
|
||||
|
||||
Args:
|
||||
issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I".
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of project items associated with the issue.
|
||||
"""
|
||||
query = """
|
||||
query($issueId: ID!) {
|
||||
node(id: $issueId) {
|
||||
... on Issue {
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
project {
|
||||
id
|
||||
title
|
||||
}
|
||||
id
|
||||
fieldValues(first: 20) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
field {
|
||||
... on ProjectV2SingleSelectField {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"issueId": issue_node_id}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return resp["data"]["node"]["projectItems"]["nodes"]
|
||||
|
||||
def get_status_field_id(
|
||||
self,
|
||||
field_name: Literal[
|
||||
"Selected for Development",
|
||||
"Weekly Backlog",
|
||||
"In Development",
|
||||
"Ready For Review",
|
||||
"On Hold",
|
||||
"Done",
|
||||
],
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Get the status field ID and option ID for the given field name in the project.
|
||||
|
||||
Args:
|
||||
field_name (str): The name of the field to retrieve.
|
||||
Must be one of the predefined statuses.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: A tuple containing the field ID and option ID.
|
||||
"""
|
||||
field_id = None
|
||||
option_id = None
|
||||
project_fields = self.get_project_fields()
|
||||
for field in project_fields:
|
||||
if field["name"] != "Status":
|
||||
continue
|
||||
field_id = field["id"]
|
||||
for option in field["options"]:
|
||||
if option["name"] == field_name:
|
||||
option_id = option["id"]
|
||||
break
|
||||
if not field_id or not option_id:
|
||||
raise ValueError(f"Field '{field_name}' not found in project fields.")
|
||||
|
||||
return field_id, option_id
|
||||
|
||||
def set_field_option(self, item_id, field_id, option_id):
|
||||
"""
|
||||
Set the option of a project item for a single-select field.
|
||||
|
||||
Args:
|
||||
item_id (str): The ID of the project item to update.
|
||||
field_id (str): The ID of the field to update.
|
||||
option_id (str): The ID of the option to set.
|
||||
"""
|
||||
|
||||
mutation = """
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {
|
||||
"projectId": self.project_node_id,
|
||||
"itemId": item_id,
|
||||
"fieldId": field_id,
|
||||
"optionId": option_id,
|
||||
}
|
||||
return self.run_graphql(mutation, variables)
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_project_fields(self) -> list[dict]:
|
||||
"""
|
||||
Get the available fields in the project.
|
||||
This method caches the result to avoid multiple API calls.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of fields in the project.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query($projectId: ID!) {
|
||||
node(id: $projectId) {
|
||||
... on ProjectV2 {
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"projectId": self.project_node_id}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return list(filter(bool, resp["data"]["node"]["fields"]["nodes"]))
|
||||
|
||||
def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]:
|
||||
"""
|
||||
Get the linked issues of a pull request.
|
||||
|
||||
Args:
|
||||
pr_number (int): The pull request number.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of linked issues.
|
||||
"""
|
||||
query = """
|
||||
query($number: Int!, $owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
id
|
||||
closingIssuesReferences(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
body
|
||||
number
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {
|
||||
"number": pr_number,
|
||||
"owner": self.gh_config.organization,
|
||||
"repo": self.gh_config.repository,
|
||||
}
|
||||
resp = self.run_graphql(query, variables)
|
||||
edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
|
||||
return [edge["node"] for edge in edges if edge.get("node")]
|
||||
|
||||
|
||||
def main():
|
||||
# GitHub settings
|
||||
token = os.getenv("TOKEN")
|
||||
org = os.getenv("ORG")
|
||||
repo = os.getenv("REPO")
|
||||
project_number = os.getenv("PROJECT_NUMBER")
|
||||
pr_number = os.getenv("PR_NUMBER")
|
||||
|
||||
if not token:
|
||||
raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.")
|
||||
if not org:
|
||||
raise ValueError("GitHub organization is not set. Please set the ORG environment variable.")
|
||||
if not repo:
|
||||
raise ValueError("GitHub repository is not set. Please set the REPO environment variable.")
|
||||
if not project_number:
|
||||
raise ValueError(
|
||||
"GitHub project number is not set. Please set the PROJECT_NUMBER environment variable."
|
||||
)
|
||||
if not pr_number:
|
||||
raise ValueError(
|
||||
"Pull request number is not set. Please set the PR_NUMBER environment variable."
|
||||
)
|
||||
|
||||
project_number = int(project_number)
|
||||
pr_number = int(pr_number)
|
||||
|
||||
gh_config = GHConfig(
|
||||
token=token,
|
||||
organization=org,
|
||||
repository=repo,
|
||||
project_number=project_number,
|
||||
graphql_url="https://api.github.com/graphql",
|
||||
rest_url=f"https://api.github.com/repos/{org}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
|
||||
)
|
||||
project_item_handler = ProjectItemHandler(gh_config=gh_config)
|
||||
|
||||
# Get PR info
|
||||
pr = project_item_handler.repo.get_pull(pr_number)
|
||||
|
||||
# Get the linked issues of the pull request
|
||||
linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number)
|
||||
print(f"Linked issues: {linked_issues}")
|
||||
|
||||
target_status = "In Development" if pr.draft else "Ready For Review"
|
||||
print(f"Target status: {target_status}")
|
||||
for issue in linked_issues:
|
||||
project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pydantic
|
||||
pygithub
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -17,10 +17,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
|
||||
19
.github/workflows/end2end-conda.yml
vendored
19
.github/workflows/end2end-conda.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
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
|
||||
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"
|
||||
@@ -23,16 +23,15 @@ jobs:
|
||||
- name: Set up Conda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
auto-activate-base: true
|
||||
python-version: "3.11"
|
||||
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
|
||||
sudo apt-get -y install ttyd
|
||||
|
||||
- name: Conda install and run pytest
|
||||
run: |
|
||||
@@ -55,5 +54,5 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./bec/logs/*.log
|
||||
retention-days: 7
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
43
.github/workflows/sync-issues-pr.yml
vendored
43
.github/workflows/sync-issues-pr.yml
vendored
@@ -2,18 +2,7 @@ name: Sync PR to Project
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
assigned,
|
||||
unassigned,
|
||||
edited,
|
||||
ready_for_review,
|
||||
converted_to_draft,
|
||||
reopened,
|
||||
synchronize,
|
||||
closed,
|
||||
]
|
||||
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
sync-project:
|
||||
@@ -24,12 +13,28 @@ jobs:
|
||||
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: Sync PR to Project
|
||||
uses: bec-project/action-issue-sync-pr@v1
|
||||
- name: Set up python environment
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
token: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
|
||||
org: ${{ github.repository_owner }}
|
||||
repo: ${{ github.event.repository.name }}
|
||||
project-number: 3
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
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
|
||||
763
CHANGELOG.md
763
CHANGELOG.md
@@ -1,769 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.2.4 (2026-03-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_app**: Setapplicationname("bec")
|
||||
([`28be696`](https://github.com/bec-project/bec_widgets/commit/28be696f7c7d9762c742c6d5fb5b03867d5e92ea))
|
||||
|
||||
|
||||
## v3.2.3 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Check adding parent for filesystemmodel
|
||||
([`b9145d7`](https://github.com/bec-project/bec_widgets/commit/b9145d762cdf946f184834928a6404f21b4802a9))
|
||||
|
||||
- Refactor client mock with global fakeredis
|
||||
([`37a5dc2`](https://github.com/bec-project/bec_widgets/commit/37a5dc2e9eeb447d174f4d7087051672f308c84c))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Fix path for uploading logs on failure
|
||||
([`1351fcd`](https://github.com/bec-project/bec_widgets/commit/1351fcd47b909c1a33cb389c096041eb1449e3d3))
|
||||
|
||||
|
||||
## v3.2.2 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Disconnecting of 2d monitor
|
||||
([`4c9d7fd`](https://github.com/bec-project/bec_widgets/commit/4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28))
|
||||
|
||||
|
||||
## v3.2.1 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **e2e**: Bec dock rpc fixed synchronization
|
||||
([`e061fa3`](https://github.com/bec-project/bec_widgets/commit/e061fa31a9a5e5c00e44337d7cc52c51d8e259b5))
|
||||
|
||||
- **e2e**: Bec shell excluded from e2e testing
|
||||
([`974f259`](https://github.com/bec-project/bec_widgets/commit/974f25997d68d13ff1063026f9e5c4c8dd4d49f3))
|
||||
|
||||
- **e2e**: Timeout for maybe_remove_dock_area
|
||||
([`718f995`](https://github.com/bec-project/bec_widgets/commit/718f99527c3bebb96845d3305aba69434eb83f77))
|
||||
|
||||
|
||||
## v3.2.0 (2026-03-11)
|
||||
|
||||
### Features
|
||||
|
||||
- **curve, waveform**: Add dap_parameters for lmfit customization in DAP requests
|
||||
([`14d51b8`](https://github.com/bec-project/bec_widgets/commit/14d51b80169f5a060dd24287f3a6db9a4b41275a))
|
||||
|
||||
- **waveform**: Composite DAP with multiple models
|
||||
([`b4f6f5a`](https://github.com/bec-project/bec_widgets/commit/b4f6f5aa8bcd0f6091610e8f839ea265c87575e0))
|
||||
|
||||
|
||||
## v3.1.4 (2026-03-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **profile_utils**: Renamed to fetch widgets settings
|
||||
([`53e5ec4`](https://github.com/bec-project/bec_widgets/commit/53e5ec42b8b33397af777f418fbd8601628226a6))
|
||||
|
||||
### Build System
|
||||
|
||||
- Increased minimal version of bec and bec qthemes
|
||||
([`7e0e391`](https://github.com/bec-project/bec_widgets/commit/7e0e391888f2ee4e8528ccb3938e36da4c32f146))
|
||||
|
||||
|
||||
## v3.1.3 (2026-03-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **monaco_dock**: Optimization, removal of QTimer, eventFilter replaced by signal/slot
|
||||
([`278d8de`](https://github.com/bec-project/bec_widgets/commit/278d8de058c2f5c6c9aa7317e1026651d7a4acd3))
|
||||
|
||||
|
||||
## v3.1.2 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Remove old AdvancedDockArea references
|
||||
([`4382d5c`](https://github.com/bec-project/bec_widgets/commit/4382d5c9b1fdac4048692eec53dd43127d67467b))
|
||||
|
||||
### Build System
|
||||
|
||||
- **deps**: Update isort requirement
|
||||
([`8463b32`](https://github.com/bec-project/bec_widgets/commit/8463b327923f853cfa1462bc22be1e83d4fd9a75))
|
||||
|
||||
|
||||
## v3.1.1 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner box**: Include username in scan queue request
|
||||
([`419c01b`](https://github.com/bec-project/bec_widgets/commit/419c01bdd4e80d927761634b03723319b0a58694))
|
||||
|
||||
### Build System
|
||||
|
||||
- Update min bec dependency to 3.106
|
||||
([`e2daf2e`](https://github.com/bec-project/bec_widgets/commit/e2daf2e89cd25d4dcedd4895299dbbdc6b7e354f))
|
||||
|
||||
- **deps**: Upgrade to black 26
|
||||
([`e157f0d`](https://github.com/bec-project/bec_widgets/commit/e157f0d7c9bb5b4d93f63ebe6f9a715a314aa1f4))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **black**: Black 26 applied
|
||||
([`d4e037f`](https://github.com/bec-project/bec_widgets/commit/d4e037f3384765e7bb8fb020cecbf3db24fc7494))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix import of bec_lib json extended
|
||||
([`ef12331`](https://github.com/bec-project/bec_widgets/commit/ef1233163cb7c3229630543fe88dbceaccd09297))
|
||||
|
||||
|
||||
## v3.1.0 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **forms**: Use FieldInfo title for label text in _add_griditem method
|
||||
([`5e34c8a`](https://github.com/bec-project/bec_widgets/commit/5e34c8a3518f24722267b3cde2dd9d3494e350b0))
|
||||
|
||||
- **scan metadata**: Set scan_name to current scan if empty in form data
|
||||
([`72e66cf`](https://github.com/bec-project/bec_widgets/commit/72e66cf57f1d47851728448e2e0f776cd8e278f2))
|
||||
|
||||
### Features
|
||||
|
||||
- **bec_queue**: Add tooltip support for user metadata in queue display
|
||||
([`56f16b6`](https://github.com/bec-project/bec_widgets/commit/56f16b63528b5a50f5a2e2d2e9dd93f3993e50e4))
|
||||
|
||||
- **scan control**: Wrap metadata form in a group box for better organization
|
||||
([`e6b41b4`](https://github.com/bec-project/bec_widgets/commit/e6b41b4e92a1ffd0494c2bde6a782347c2364114))
|
||||
|
||||
- **scan queue**: Add scan name to queue
|
||||
([`ab3efdb`](https://github.com/bec-project/bec_widgets/commit/ab3efdbd0a0a80293ba2121e78ea319ddbbd8f82))
|
||||
|
||||
- **StrFormItem**: Set placeholder text from spec description
|
||||
([`ac824f6`](https://github.com/bec-project/bec_widgets/commit/ac824f6b83178e34f015c296008d7a1e21c70878))
|
||||
|
||||
### Testing
|
||||
|
||||
- Adjust metadata assertions to new schema defaults
|
||||
([`75697f5`](https://github.com/bec-project/bec_widgets/commit/75697f5b1faefb5cfcbc1b753d3f505d69339559))
|
||||
|
||||
- Adjust metadata assertions to new schema defaults
|
||||
([`2697496`](https://github.com/bec-project/bec_widgets/commit/26974965151748c57334f350e21f3b610f92e011))
|
||||
|
||||
|
||||
## v3.0.0 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 'any' type annotations
|
||||
([`9c4a544`](https://github.com/bec-project/bec_widgets/commit/9c4a54493adc94afe5d43db5e8cbb8d565670af2))
|
||||
|
||||
- Add metadata to scan control export
|
||||
([`17e678b`](https://github.com/bec-project/bec_widgets/commit/17e678b0ad1739490e901f3dbf7180d99c96950c))
|
||||
|
||||
- Address copilot review
|
||||
([`a1a400f`](https://github.com/bec-project/bec_widgets/commit/a1a400f5409213ee1ab2f7cc9f8da7a2b612972d))
|
||||
|
||||
- Adjust ring progress bar to ads
|
||||
([`7fd7f67`](https://github.com/bec-project/bec_widgets/commit/7fd7f67857e23b04759cf23993a99f4701121f95))
|
||||
|
||||
- Don't wait forever
|
||||
([`c1d0e43`](https://github.com/bec-project/bec_widgets/commit/c1d0e435d5dd9965dbafd5bf469327c7f7620cfd))
|
||||
|
||||
- Removal of old BECDock import
|
||||
([`92ae5fc`](https://github.com/bec-project/bec_widgets/commit/92ae5fc7fbf3a55068e2b42d3f66134baeb71766))
|
||||
|
||||
- Remove manual stylesheet deletion/override
|
||||
([`8bbd519`](https://github.com/bec-project/bec_widgets/commit/8bbd519559c857cdc9f51e9507994e7aa4b07af1))
|
||||
|
||||
- Remove singleShots from BECConnector and adjustments of dock area logic
|
||||
([`e26a90c`](https://github.com/bec-project/bec_widgets/commit/e26a90c62fa6c176bf4425867d1cb895a6fad7cd))
|
||||
|
||||
- Sanitize name space util for bec connector and ads
|
||||
([`beca23e`](https://github.com/bec-project/bec_widgets/commit/beca23e14e18445f6ee440e8c55b57f4180a36c9))
|
||||
|
||||
- Tooltip logic and disable button on running scan
|
||||
([`fa56fc8`](https://github.com/bec-project/bec_widgets/commit/fa56fc88026521f6f13690c4ec621c79e318f434))
|
||||
|
||||
- **_OverlayEventFilter**: Fix typo
|
||||
([`a9f92cf`](https://github.com/bec-project/bec_widgets/commit/a9f92cf15547d207a614a1ed08b5d763a569fe59))
|
||||
|
||||
- **advanced_dock_area**: Cli API adjustments docs + names
|
||||
([`6883982`](https://github.com/bec-project/bec_widgets/commit/6883982bf67c5fff02d72fbe39425af39bc3a65e))
|
||||
|
||||
- **advanced_dock_area**: Empty profile is always empty
|
||||
([`aba67d3`](https://github.com/bec-project/bec_widgets/commit/aba67d3129581c85467ddd83211a03ea51c157a3))
|
||||
|
||||
- **advanced_dock_area**: Ensure the general profile exists when launched first time
|
||||
([`7d2760e`](https://github.com/bec-project/bec_widgets/commit/7d2760eab8e5494992adb1452705f58619842d30))
|
||||
|
||||
- **advanced_dock_area**: New profiles are saved with quickselect as default
|
||||
([`0d6b94a`](https://github.com/bec-project/bec_widgets/commit/0d6b94aaecb56e51bdc1ff930079b6c5535798de))
|
||||
|
||||
- **advanced_dock_area**: Profile behaviour adjusted, cleanup of the codebase
|
||||
([`22df7bb`](https://github.com/bec-project/bec_widgets/commit/22df7bb5320c3b1808ab21e6354350838f5acb63))
|
||||
|
||||
- **advanced_dock_area**: Remove all widgets when loading new profiles
|
||||
([`b841cfb`](https://github.com/bec-project/bec_widgets/commit/b841cfbc5f5021c1f9bea03e7fe88713506f66a7))
|
||||
|
||||
- **advanced_dock_area**: Remove widget from dock area by object name
|
||||
([`8f44213`](https://github.com/bec-project/bec_widgets/commit/8f44213ecccca882f22b8738baef28b68d99c381))
|
||||
|
||||
- **advanced_dock_area**: Removed non-functional dock_list and dock_map from RPC
|
||||
([`88b6e01`](https://github.com/bec-project/bec_widgets/commit/88b6e015bf1ab3b56db843ec13a6473ad67c4acc))
|
||||
|
||||
- **advanced_dock_area**: Removed the singleShot for load_initial_profile
|
||||
([`3236dfb`](https://github.com/bec-project/bec_widgets/commit/3236dfb07f477fb87bcbcd0ee983781d5281beb6))
|
||||
|
||||
- **advanced_dock_area**: Replace sanitize_namespace with slugify
|
||||
([`013b916`](https://github.com/bec-project/bec_widgets/commit/013b916ca3beb7a47db9009b9e07250ae52979b1))
|
||||
|
||||
- **basic_dock_area**: Delete_all will also delete floating docks
|
||||
([`6b2b42f`](https://github.com/bec-project/bec_widgets/commit/6b2b42f21afa98d4ee5cb9d969aaa21cfc633f4e))
|
||||
|
||||
- **basic_dock_area**: Removed the singleShot usage
|
||||
([`6cff8d7`](https://github.com/bec-project/bec_widgets/commit/6cff8d7a41f6f08908c3dd20fd563ab2612976e3))
|
||||
|
||||
- **bec_connector**: Use RPC register to fetch all connections
|
||||
([`56b1e66`](https://github.com/bec-project/bec_widgets/commit/56b1e6687f4ce56e7c836678d397d1ca0fbec459))
|
||||
|
||||
- **bec_connector**: Widget_removed and name_established signals added
|
||||
([`389a93f`](https://github.com/bec-project/bec_widgets/commit/389a93f8d07d44c17772e6183ee129db7692bd89))
|
||||
|
||||
- **bec_widget**: Improved qt enums; grab safeguard
|
||||
([`f38cd3e`](https://github.com/bec-project/bec_widgets/commit/f38cd3e3a043151ce25f91d9a6b325a6c6ac5103))
|
||||
|
||||
- **bec_widgets**: Adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects
|
||||
([`b0cd619`](https://github.com/bec-project/bec_widgets/commit/b0cd619d7dff8f7ce7bc37ea6acea9473b2273d8))
|
||||
|
||||
- **becconnector**: Ophyd thread killer on exit + in conftest
|
||||
([`0b9e5c1`](https://github.com/bec-project/bec_widgets/commit/0b9e5c15afb8b6f271992cb70c235c2be44c24a8))
|
||||
|
||||
- **becconnector**: Sanitize the setObjectName from qobject inheritance
|
||||
([`7507f27`](https://github.com/bec-project/bec_widgets/commit/7507f27d686300a2b42c80dc06f3c78142c7ef84))
|
||||
|
||||
- **busy-loader**: Adjust busy loader and tests
|
||||
([`94faaba`](https://github.com/bec-project/bec_widgets/commit/94faaba24d45a1ff971879486fa044fce49d2d5c))
|
||||
|
||||
- **CLI**: Change the default behavior of launching the profiles in CLI
|
||||
([`b43b6e8`](https://github.com/bec-project/bec_widgets/commit/b43b6e844b4f178f9636b325aee0ce4fa2152199))
|
||||
|
||||
- **CLI**: Dock_area can be created from CLI with specific profile or empty
|
||||
([`9c66dd5`](https://github.com/bec-project/bec_widgets/commit/9c66dd59914e2c8964f811f4e7e522fd3ae75633))
|
||||
|
||||
- **cli**: Rpc API from any folder
|
||||
([`b29648e`](https://github.com/bec-project/bec_widgets/commit/b29648e10b0ea7931ad216221f231b77ab8998d8))
|
||||
|
||||
- **client**: Abort, reset, stop button removed from RPC access
|
||||
([`c923f79`](https://github.com/bec-project/bec_widgets/commit/c923f7929370c3ac721dfa84d7cafcd0aa406c92))
|
||||
|
||||
- **client**: Client API regenerated
|
||||
([`7083f94`](https://github.com/bec-project/bec_widgets/commit/7083f94f467ad4d40bea57dcdc96c75aa3690910))
|
||||
|
||||
- **client_utils**: Delete is deleting window and its content
|
||||
([`be55bf2`](https://github.com/bec-project/bec_widgets/commit/be55bf20c1295c1e710457638c1bc7154b23011e))
|
||||
|
||||
- **client_utils**: Safeguard for accessing gui.new and launcher if GUIServer not running
|
||||
([`4d41be6`](https://github.com/bec-project/bec_widgets/commit/4d41be61b546931c728b584f190aa4de3f418dd3))
|
||||
|
||||
- **colors**: Added logger to the apply theme
|
||||
([`1f363d9`](https://github.com/bec-project/bec_widgets/commit/1f363d9bd4e6f7a01edcbe5d0049560459d184d0))
|
||||
|
||||
- **colors**: More benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping
|
||||
from GradientWidget from pg
|
||||
([`cd9c7ab`](https://github.com/bec-project/bec_widgets/commit/cd9c7ab079bee1623a93ff63142cac8ebf61facd))
|
||||
|
||||
- **dark_mode_button**: Rpc access disabled
|
||||
([`4fc2522`](https://github.com/bec-project/bec_widgets/commit/4fc252220d3a22f52b1148ba64045f5884d59182))
|
||||
|
||||
- **dark_mode_button**: Skip settings added
|
||||
([`1c18810`](https://github.com/bec-project/bec_widgets/commit/1c18810e5faf0de96bb7381db3d8c4bcd2596596))
|
||||
|
||||
- **developer widget**: Save before executing a scripts
|
||||
([`d085f65`](https://github.com/bec-project/bec_widgets/commit/d085f651532f84e720506745dbd44b80fb05a4be))
|
||||
|
||||
- **device-form-dialog**: Adapt device-form-dialog ophyd validation test
|
||||
([`36be529`](https://github.com/bec-project/bec_widgets/commit/36be5292da1a2c30ef9a8493ad49f361d878c23a))
|
||||
|
||||
- **device-form-dialog**: Adapt DeviceFormDialog to run validation of config upon editing/adding a
|
||||
config, and forward validation results
|
||||
([`7c28364`](https://github.com/bec-project/bec_widgets/commit/7c283645948999f6a6b2e480418e5c8c7f158fb5))
|
||||
|
||||
- **device-init-progress-bar**: Fix ui format for device init progressbar
|
||||
([`caba3a5`](https://github.com/bec-project/bec_widgets/commit/caba3a55f3a7a62a74f8f36b14a960e9c0fe0981))
|
||||
|
||||
- **device-manager**: Fix minor icon synchronization bugs
|
||||
([`1d654bd`](https://github.com/bec-project/bec_widgets/commit/1d654bd8bdaac581a934cb9bab5a64a9021b4972))
|
||||
|
||||
- **device-manager-display-widget**: Fix error message popup on cancelling upload
|
||||
([`fa49322`](https://github.com/bec-project/bec_widgets/commit/fa49322d1fd94ec4235c435dd6ca5e5234cd6bcc))
|
||||
|
||||
- **device-manager-display-widget**: Remove devices from ophyd validation after upload to BEC
|
||||
([`7805c7a`](https://github.com/bec-project/bec_widgets/commit/7805c7a1916d8d153881eaf6b96825a010ad6a9c))
|
||||
|
||||
- **device-progress-bar**: Remove stretch in content layout
|
||||
([`3fe6a00`](https://github.com/bec-project/bec_widgets/commit/3fe6a00708c459595b2eedb2a902c4ca5cae7171))
|
||||
|
||||
- **device_combobox**: Public flag for valid input
|
||||
([`6c73307`](https://github.com/bec-project/bec_widgets/commit/6c73307bb43dfc2ae6181bd4be3854b7e198eb1d))
|
||||
|
||||
- **device_input_widgets**: Removed RPC access
|
||||
([`940face`](https://github.com/bec-project/bec_widgets/commit/940face1187a0d3480ca3d64c061550271ff54e4))
|
||||
|
||||
- **dock_area**: Profile management with empty profile, applied across the whole repo
|
||||
([`963941a`](https://github.com/bec-project/bec_widgets/commit/963941a788c1ce8a5def15b9a9d930ef9c62f41e))
|
||||
|
||||
- **dock_area**: Tabbed dock have correct parent
|
||||
([`a632f35`](https://github.com/bec-project/bec_widgets/commit/a632f35c40e8323378f2464a6a82a484edf4ff33))
|
||||
|
||||
- **dock_area**: The old BECDockArea(pg) removed and replaces by AdvancedDockArea(ADS)
|
||||
([`a6583ad`](https://github.com/bec-project/bec_widgets/commit/a6583ad53f6a1004af1a87904517d97a52801116))
|
||||
|
||||
- **dock_area**: Widget_map and widget_list by default returns only becconnector based widgets
|
||||
([`3a5317b`](https://github.com/bec-project/bec_widgets/commit/3a5317be53d21130203a534b0dbf6bbef2d1a1c8))
|
||||
|
||||
- **editors**: Vscode widget removed
|
||||
([`48387c0`](https://github.com/bec-project/bec_widgets/commit/48387c0ad9234f5f7600644eb12fa12c6d29efa7))
|
||||
|
||||
- **FakeDevice**: Add _info dict
|
||||
([`2992939`](https://github.com/bec-project/bec_widgets/commit/2992939b0fa504418fe06173c11702e9dd4f3ce2))
|
||||
|
||||
- **general_app**: Old general app example removed
|
||||
([`3ebac55`](https://github.com/bec-project/bec_widgets/commit/3ebac55e2d6aabf971d818fddb53430a690a7392))
|
||||
|
||||
- **guided-tour**: Fix skip past invalid step for 'prev' step
|
||||
([`7bcdc31`](https://github.com/bec-project/bec_widgets/commit/7bcdc31f119b7b0996c7eac75008cef0b6e880ff))
|
||||
|
||||
- **heatmap**: Devices are saved as SafeProperties
|
||||
([`6baf196`](https://github.com/bec-project/bec_widgets/commit/6baf1962faa0628ba872790e6cb34565bc7d0d7c))
|
||||
|
||||
- **heatmap**: Interpolation of the image moved to separate thread
|
||||
([`323c8d5`](https://github.com/bec-project/bec_widgets/commit/323c8d5bc00f12b2d032f3da5daa47ef3e4774bc))
|
||||
|
||||
- **heatmap**: Interpolation thread is killed only on exit, logger for dandling thread
|
||||
([`6fc524c`](https://github.com/bec-project/bec_widgets/commit/6fc524c819903eedd690adcb09f7aa70ee4d2248))
|
||||
|
||||
- **launch_window**: Argument to start with the gui class
|
||||
([`3c16909`](https://github.com/bec-project/bec_widgets/commit/3c16909a875337efdec9e984f952c390ce99cfb4))
|
||||
|
||||
- **launch_window**: Launch geometry for widgets launched from launcher to 80% of the primary screen
|
||||
as default
|
||||
([`6459281`](https://github.com/bec-project/bec_widgets/commit/6459281387c8f1287347b9569a77aa1e9444013c))
|
||||
|
||||
- **launch_window**: Logic for showing launcher
|
||||
([`d9b7285`](https://github.com/bec-project/bec_widgets/commit/d9b728584fb7e96ebac1c0f29f713290c0092556))
|
||||
|
||||
- **launch_window**: Processevents removed
|
||||
([`c61d00e`](https://github.com/bec-project/bec_widgets/commit/c61d00e761851a67003921c2ad689238e360ad77))
|
||||
|
||||
- **main_app**: Center the application window on the screen
|
||||
([`96a52a0`](https://github.com/bec-project/bec_widgets/commit/96a52a0cb0fb248e83303ee89182fe4ebeb29e75))
|
||||
|
||||
- **main_app**: Dock area from main app shares the workspace name with the CLI one to reuse the
|
||||
profiles created in the cli companion window
|
||||
([`06745e0`](https://github.com/bec-project/bec_widgets/commit/06745e0511d3ad4e261119118c7767f92bd884a5))
|
||||
|
||||
- **main_app**: Refactor main function and update script entry point in pyproject.toml
|
||||
([`7ccfcc9`](https://github.com/bec-project/bec_widgets/commit/7ccfcc9f52c6ddaf65c350d474bac7260e3dd059))
|
||||
|
||||
- **main_app**: Rpc access refined
|
||||
([`5bcf440`](https://github.com/bec-project/bec_widgets/commit/5bcf440be7172f8c3cadc7cd1d95251c176d33d1))
|
||||
|
||||
- **main_app**: Temporarily disable IDE view
|
||||
([`bfc9f19`](https://github.com/bec-project/bec_widgets/commit/bfc9f1947234b87835d2cde87f961a00b1a0990d))
|
||||
|
||||
- **main_app**: The dock area view implemented as a viewBase
|
||||
([`ab9688d`](https://github.com/bec-project/bec_widgets/commit/ab9688d2b551e4b3525fe9aed76afd772b835b05))
|
||||
|
||||
- **main_window**: Cleanup adjusted with shiboken6
|
||||
([`06cb187`](https://github.com/bec-project/bec_widgets/commit/06cb187d1a030e24d62c5a8e01978ba68f4812df))
|
||||
|
||||
- **main_window**: Delete on close
|
||||
([`522934f`](https://github.com/bec-project/bec_widgets/commit/522934f8cd814c07fde8c62635f2f63ed716e00e))
|
||||
|
||||
- **main_window**: Parent fixed for notification broker
|
||||
([`947bf63`](https://github.com/bec-project/bec_widgets/commit/947bf63e03b3cbdfe2fd8ab803c83175c7bc599b))
|
||||
|
||||
- **main_window**: Removed general forced cleanup
|
||||
([`cab4227`](https://github.com/bec-project/bec_widgets/commit/cab422777c50151b94da71a45a9bda0e1ce2804d))
|
||||
|
||||
- **main_window**: Safeguard of fetching the launcher from the main window if GUIServer is not
|
||||
running
|
||||
([`f8be437`](https://github.com/bec-project/bec_widgets/commit/f8be43741a5c100a976d2f84c3dc7607938c847e))
|
||||
|
||||
- **main_window**: Scan progress bar rpc not exposed
|
||||
([`04b448e`](https://github.com/bec-project/bec_widgets/commit/04b448e1832796616002a1ea26028e3d42aca9b1))
|
||||
|
||||
- **monaco dock**: Update last focused editor when closing
|
||||
([`3631fc2`](https://github.com/bec-project/bec_widgets/commit/3631fc26499853015ff58283c2b8913aa9a36334))
|
||||
|
||||
- **monaco widget**: Reset current_file
|
||||
([`c53d4c0`](https://github.com/bec-project/bec_widgets/commit/c53d4c0ad7b4c423eaa13828e2b38a04751f148e))
|
||||
|
||||
- **monaco_dock**: Update editor metadata handling and improve open_file method
|
||||
([`3136477`](https://github.com/bec-project/bec_widgets/commit/31364772bd7fcccbc118061d0b601a9f1121bcb0))
|
||||
|
||||
- **motor_map**: X/y motor are saved in properties
|
||||
([`96060fc`](https://github.com/bec-project/bec_widgets/commit/96060fca53f3426dbc43f1ae5d8ebdd7acc39100))
|
||||
|
||||
- **ophyd-validation**: Add device_manager_ds argument if available for ophyd validation
|
||||
([`338ff45`](https://github.com/bec-project/bec_widgets/commit/338ff455cccfc1e8a3b0638fdcc4f1d807f0b6ca))
|
||||
|
||||
- **positioner_box**: Layout HV centered and size taken from the ui file
|
||||
([`6113deb`](https://github.com/bec-project/bec_widgets/commit/6113debc6c1d95a50b7522144fdc820380ae2e28))
|
||||
|
||||
- **qt_ads**: Pythons stubs match structure of PySide6QtAds
|
||||
([`2f9d6d5`](https://github.com/bec-project/bec_widgets/commit/2f9d6d59eee32e373acc0df8a38b426d8142562b))
|
||||
|
||||
- **rpc**: Rpc flags adjustment for MainApp and DeveloperWidget
|
||||
([`5b15c75`](https://github.com/bec-project/bec_widgets/commit/5b15c75b88707f450bfa194d9eed3d726e101981))
|
||||
|
||||
- **rpc_register**: Listing only valid connections
|
||||
([`38eb244`](https://github.com/bec-project/bec_widgets/commit/38eb2441cdf677939354c7066f854c22cf261932))
|
||||
|
||||
- **rpc_server**: Add check for rpc_exposed to serialize_object
|
||||
([`0eabd0f`](https://github.com/bec-project/bec_widgets/commit/0eabd0f72be6247073382d0df02776d30c35a1aa))
|
||||
|
||||
- **rpc_server**: Removed unused get _get_becwidget_ancestor
|
||||
([`047ff2b`](https://github.com/bec-project/bec_widgets/commit/047ff2bef77ca14f060b3b0bc21f78b880535faa))
|
||||
|
||||
- **rpc_server**: Use single shot instead of processEvents to avoid dead locks
|
||||
([`84d6653`](https://github.com/bec-project/bec_widgets/commit/84d6653d1993dd4bebb98fcbf0d1a0dd94119502))
|
||||
|
||||
- **scatter waveform**: Fix tab order for settings panel
|
||||
([`08e1985`](https://github.com/bec-project/bec_widgets/commit/08e19858eadb738358465c9f2a202529d1ccbe45))
|
||||
|
||||
- **scatter_waveform**: Devices and entries saved as properties
|
||||
([`7ab8e0c`](https://github.com/bec-project/bec_widgets/commit/7ab8e0c2ed4f1b49e943f7ec64d3984ede6e134a))
|
||||
|
||||
- **scatter_waveform**: Modernization of scatter waveform settings dialog
|
||||
([`dea73a9`](https://github.com/bec-project/bec_widgets/commit/dea73a97c9f78560e9f11290ba442152cc955057))
|
||||
|
||||
- **scatter_waveform**: Remove curve_json from the properties
|
||||
([`f6712e8`](https://github.com/bec-project/bec_widgets/commit/f6712e8bb855566ca0f308ae3d5bf5109d98d792))
|
||||
|
||||
- **screen_utils**: Screen utilities added and fixed sizing for widgets from launch window and main
|
||||
app
|
||||
([`fb55e72`](https://github.com/bec-project/bec_widgets/commit/fb55e72713a2209575c555c9dd8c025a0349e795))
|
||||
|
||||
- **server**: Gui server can reach shutdown, logic moved to becconnector
|
||||
([`0d05839`](https://github.com/bec-project/bec_widgets/commit/0d05839e9e3f4c61fc318aa44721436afcebf06f))
|
||||
|
||||
- **signal-label**: Fix signal label cleanup, missing parent in constructors
|
||||
([`72639e7`](https://github.com/bec-project/bec_widgets/commit/72639e7e5fa01ceac6cc864c01cea73f4ddca441))
|
||||
|
||||
- **signal_combo_box**: Get_signal_name added; remove duplicates from heatmap and scatter waveform
|
||||
settings;
|
||||
([`66a9510`](https://github.com/bec-project/bec_widgets/commit/66a95102dd33dbac5575a3b0d99c4c99c42cce4a))
|
||||
|
||||
- **signal_label**: Dispatcher unsubscribed in the cleanup
|
||||
([`90ba505`](https://github.com/bec-project/bec_widgets/commit/90ba505c10e7ee60d82abb578c7f691cf1125e9a))
|
||||
|
||||
- **toggle**: Move toggle to theme colors
|
||||
([`375d131`](https://github.com/bec-project/bec_widgets/commit/375d131109d37ea7b49aa354b624b0dd8fea89ee))
|
||||
|
||||
- **view**: Based on BECWidgets
|
||||
([`3d049d6`](https://github.com/bec-project/bec_widgets/commit/3d049d67a9303b20862150b3622c4121d4a72b32))
|
||||
|
||||
- **web_console**: Added startup kwarg
|
||||
([`55c8a57`](https://github.com/bec-project/bec_widgets/commit/55c8a57e71653299f3fd66ca7aafca8f32c7aacc))
|
||||
|
||||
- **widget_state_manager**: Added shiboken check
|
||||
([`338b9e1`](https://github.com/bec-project/bec_widgets/commit/338b9e1aa7216d9d38449633fe9d4fffce13ee90))
|
||||
|
||||
- **widget_state_manager**: Filtering of not wanted properties
|
||||
([`7ea4352`](https://github.com/bec-project/bec_widgets/commit/7ea4352a09349e606c97edb72eccf6e683684cf8))
|
||||
|
||||
- **widget_state_manager**: Properties_to_skip are not restored even if in ini file
|
||||
([`84c7360`](https://github.com/bec-project/bec_widgets/commit/84c7360bb8a63426d584a522d6a8969810536d2a))
|
||||
|
||||
- **widget_state_manager**: State manager can save all properties recursively to already existing
|
||||
settings
|
||||
([`98e2979`](https://github.com/bec-project/bec_widgets/commit/98e29792a2620a9e88c770cd69d7cad88cc94252))
|
||||
|
||||
- **widgets**: Processevent removed from widgets using it
|
||||
([`a56bd57`](https://github.com/bec-project/bec_widgets/commit/a56bd572a000e47dd7d1d2a458dac676e67ec21e))
|
||||
|
||||
- **widgets**: Removed isVisible from all SafeProperties
|
||||
([`b72bf4a`](https://github.com/bec-project/bec_widgets/commit/b72bf4a0f9a67c104cd86c66e9160ab9f0a40c01))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyside6-qtads; bec_qtheme V1; dependencies updated and adjusted
|
||||
([`562001c`](https://github.com/bec-project/bec_widgets/commit/562001c08cdc3ca9fbe28aaed8b6a83921426f97))
|
||||
|
||||
- **deps**: Update bec-qthemes requirement
|
||||
([`4a44ede`](https://github.com/bec-project/bec_widgets/commit/4a44ede8fe02b4c513ec419f85cb447f58dfdf86))
|
||||
|
||||
Updates the requirements on [bec-qthemes](https://github.com/bec-project/bec_qthemes) to permit the
|
||||
latest version. - [Release notes](https://github.com/bec-project/bec_qthemes/releases) -
|
||||
[Changelog](https://github.com/bec-project/bec_qthemes/blob/main/CHANGELOG.md) -
|
||||
[Commits](https://github.com/bec-project/bec_qthemes/compare/v0.7.0...v1.3.3)
|
||||
|
||||
--- updated-dependencies: - dependency-name: bec-qthemes dependency-version: 1.3.3
|
||||
|
||||
dependency-type: direct:production ...
|
||||
|
||||
Signed-off-by: dependabot[bot] <support@github.com>
|
||||
|
||||
### Code Style
|
||||
|
||||
- Wrap progress bar in widget to fix background
|
||||
([`793779d`](https://github.com/bec-project/bec_widgets/commit/793779db68c9725fae767d6cd0096c89a4caa700))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add artifact upload
|
||||
([`d301fdf`](https://github.com/bec-project/bec_widgets/commit/d301fdfeb237acd61fd579a0e8147f2037df62d5))
|
||||
|
||||
- Cancel previous CI run for PR or branch
|
||||
([`37298c2`](https://github.com/bec-project/bec_widgets/commit/37298c21c3b76667459f2a62453692e99ff8191e))
|
||||
|
||||
- Install ttyd
|
||||
([`b6d70c3`](https://github.com/bec-project/bec_widgets/commit/b6d70c34df29d2f44e7f5da88cb0daaef39ceed1))
|
||||
|
||||
- Use shared issue sync action instead of local version
|
||||
([`c9a8e64`](https://github.com/bec-project/bec_widgets/commit/c9a8e64217d3c2047a4a8f5e2348c0a725a0066a))
|
||||
|
||||
### Features
|
||||
|
||||
- Add export and load settings methods to BECConnector; add SafeProperty safe getter flag
|
||||
([`5435fec`](https://github.com/bec-project/bec_widgets/commit/5435fec68a11caa83e8566cde21ad382729e6792))
|
||||
|
||||
- Add guided tour docs to device-manager-view
|
||||
([`fcb4306`](https://github.com/bec-project/bec_widgets/commit/fcb43066e4abe469e0f06163b4abcce6e0d9250b))
|
||||
|
||||
- Add SafeConnect
|
||||
([`4b5a45c`](https://github.com/bec-project/bec_widgets/commit/4b5a45c320d701e6878d6af7259c530596118053))
|
||||
|
||||
- Attach config cancellation to closeEvent
|
||||
([`c1443fa`](https://github.com/bec-project/bec_widgets/commit/c1443fa27afc63c69c4b56cf8be7eb2792704784))
|
||||
|
||||
- Guided tour for main app
|
||||
([`3ffdf11`](https://github.com/bec-project/bec_widgets/commit/3ffdf11c3e419d71e22c484c618eec51e9168f9d))
|
||||
|
||||
- **actions**: Actions can be created with label text with beside or under alignment
|
||||
([`9c3a6e1`](https://github.com/bec-project/bec_widgets/commit/9c3a6e1691fd02230651a4d871911f365d4a3129))
|
||||
|
||||
- **ads**: Add pyi stub file to provide type hints for ads
|
||||
([`4c4fc25`](https://github.com/bec-project/bec_widgets/commit/4c4fc25a42be9bc8ecce6f550c4f357372233289))
|
||||
|
||||
- **advanced_dock_area**: Added ads based dock area with profiles
|
||||
([`d25314e`](https://github.com/bec-project/bec_widgets/commit/d25314e6eeb6323a6ffcde3c119f7b1bc0ebed16))
|
||||
|
||||
- **advanced_dock_area**: Created DockAreaWidget base class; profile management through namespaces;
|
||||
dock area variants
|
||||
([`58b88ef`](https://github.com/bec-project/bec_widgets/commit/58b88efcb66627f9e9c3c9de65366d55465e1e44))
|
||||
|
||||
- **advanced_dock_area**: Floating docks restore with relative geometry
|
||||
([`440cecd`](https://github.com/bec-project/bec_widgets/commit/440cecddf740a5f320f53771b93a148fb3be544b))
|
||||
|
||||
- **advanced_dock_area**: Instance lock for multiple ads in same session
|
||||
([`bcaf013`](https://github.com/bec-project/bec_widgets/commit/bcaf013d2b5b45830cc37079b7d0f388ead98bc1))
|
||||
|
||||
- **advanced_dock_area**: Ui/ux for profile management improved, saving directories logic adjusted
|
||||
([`7305498`](https://github.com/bec-project/bec_widgets/commit/730549847563b552887a5529b2b0fed308ed8b98))
|
||||
|
||||
- **bec-login**: Add login widget in material design style
|
||||
([`b798ea2`](https://github.com/bec-project/bec_widgets/commit/b798ea2340a6aa8c0325a1cd1995eba028279816))
|
||||
|
||||
- **bec_widget**: Attach/detach method for all widgets + client regenerated
|
||||
([`82dbf31`](https://github.com/bec-project/bec_widgets/commit/82dbf31da54288b7228bc5c7bdc271a8178f8d02))
|
||||
|
||||
- **bec_widget**: Save screenshot to bytes
|
||||
([`ed2651a`](https://github.com/bec-project/bec_widgets/commit/ed2651a914a283dc7cc45a9bf185d2a4e053d307))
|
||||
|
||||
- **becconnector**: Added rpc_passthrough_children flag in addition to rpc_exposed
|
||||
([`010373f`](https://github.com/bec-project/bec_widgets/commit/010373fd5b334c6616efce467608356b36c2130b))
|
||||
|
||||
- **becconnector**: Exposed rpc flag added to the BECConnector
|
||||
([`de6c628`](https://github.com/bec-project/bec_widgets/commit/de6c6284ad6d73b40137e9bba56e748c59a4ade9))
|
||||
|
||||
- **busy_loader**: Busy loader added to bec widget base class
|
||||
([`92c15a7`](https://github.com/bec-project/bec_widgets/commit/92c15a7f829fa3f0b69cf5584ac45a21dce0b01d))
|
||||
|
||||
- **client_utils**: Theme can be changed from the CLI
|
||||
([`c1d4758`](https://github.com/bec-project/bec_widgets/commit/c1d4758e4ca33d094fabdfbd4e024a2836f2fa9a))
|
||||
|
||||
- **color**: Add relative luminance calculation
|
||||
([`a84b924`](https://github.com/bec-project/bec_widgets/commit/a84b924162280fc6b6ca31af511b78c4f5baafc9))
|
||||
|
||||
- **developer_view**: Add developer view
|
||||
([`bdef594`](https://github.com/bec-project/bec_widgets/commit/bdef594b5885b5fab60ef94addbce1ab771c4244))
|
||||
|
||||
- **developer_widget**: Add signal connection for focused editor changes to disable run button for
|
||||
macro files
|
||||
([`fa79179`](https://github.com/bec-project/bec_widgets/commit/fa79179f89f048aeee0a3947350f3a7bc2169d9f))
|
||||
|
||||
- **device-initialization-progress-bar**: Add progress bar for device initialization
|
||||
([`5deafb9`](https://github.com/bec-project/bec_widgets/commit/5deafb97979eb1a2e8bcba3321dfd1a15553a5da))
|
||||
|
||||
- **device-manager**: Add DeviceManager Widget for BEC Widget main applications
|
||||
([`a6357af`](https://github.com/bec-project/bec_widgets/commit/a6357af8ffda640eaee1c1c75c3a4bdf0c5de068))
|
||||
|
||||
- **device_combobox**: Device filter added based on its signal classes
|
||||
([`fbddf4a`](https://github.com/bec-project/bec_widgets/commit/fbddf4a28442dab6e9e4585aa0c3a0131d6bdf7b))
|
||||
|
||||
- **dm-view**: Initial device manager view added
|
||||
([`9e4be38`](https://github.com/bec-project/bec_widgets/commit/9e4be38c0b8b6e654313bf232a597d09978d2436))
|
||||
|
||||
- **generate_cli**: Rpc API from content widget can be merged with the RPC API of the container
|
||||
widget statically
|
||||
([`758956b`](https://github.com/bec-project/bec_widgets/commit/758956be098d6629a0cd641b1525965ebfe19345))
|
||||
|
||||
- **guided_tour**: Add guided tour
|
||||
([`9b753c1`](https://github.com/bec-project/bec_widgets/commit/9b753c1f24419292790ca60e4bd55bb1aa5e1a70))
|
||||
|
||||
- **help-inspector**: Add help inspector widget
|
||||
([`5ac629d`](https://github.com/bec-project/bec_widgets/commit/5ac629de8c7bbdf0e2c07c9a7cf25e430cd031c1))
|
||||
|
||||
- **image**: Modernization of image widget
|
||||
([`80c0dfa`](https://github.com/bec-project/bec_widgets/commit/80c0dfa4f28e3eb2c6f944a517c92f822f51266d))
|
||||
|
||||
- **jupyter_console_window**: Adjustment for general usage
|
||||
([`66f3e51`](https://github.com/bec-project/bec_widgets/commit/66f3e517f0fb8fa1ea678ec09ef852d5b8a63d51))
|
||||
|
||||
- **main_app**: Main app with interactive app switcher
|
||||
([`b30e1e4`](https://github.com/bec-project/bec_widgets/commit/b30e1e4c5e182903721fe7c16a8069f2c95704d3))
|
||||
|
||||
- **motor_map**: Motor selection adopted to splitter action
|
||||
([`168bb3c`](https://github.com/bec-project/bec_widgets/commit/168bb3cb77ca3a270a958f4f941445383c8bec99))
|
||||
|
||||
- **plot_base**: Plot_base, image and heatmap widget adopted to property-toolbar sync
|
||||
([`dd69578`](https://github.com/bec-project/bec_widgets/commit/dd69578b912b44130d33427fa8d5d948889e8c07))
|
||||
|
||||
- **SafeProperty**: Safeproperty emits property_changed signal
|
||||
([`7cce3bd`](https://github.com/bec-project/bec_widgets/commit/7cce3bd54210f82a5cf68e6219ea073e972234d6))
|
||||
|
||||
- **signal_combobox**: Extended that can filter by signal class and dimension of the signal
|
||||
([`cfd6bde`](https://github.com/bec-project/bec_widgets/commit/cfd6bde268cea5bd119354db8b6ab1661b575293))
|
||||
|
||||
- **toolbar**: Splitter action added
|
||||
([`0752f3d`](https://github.com/bec-project/bec_widgets/commit/0752f3d6a9cd9b080bf87464eac9eb05f99f108f))
|
||||
|
||||
- **toolbar**: Toolbar can be synced with the property_changed for toggle actions
|
||||
([`4357d98`](https://github.com/bec-project/bec_widgets/commit/4357d984c8f89fa51bc0c8d9a217b2a2028e3ca9))
|
||||
|
||||
- **web console**: Add support for shared web console sessions
|
||||
([`5e111cf`](https://github.com/bec-project/bec_widgets/commit/5e111cfc54f2771a0ff5080a77bb4ac5b491bc8f))
|
||||
|
||||
- **widget_hierarchy_tree**: Widget displaying parent child hierarchy from the app widgets
|
||||
([`5f46fa0`](https://github.com/bec-project/bec_widgets/commit/5f46fa09943017fdadbe12522b38a2733d5b6001))
|
||||
|
||||
- **widget_highlighter**: Reusable separate widget highlighter
|
||||
([`8b782ac`](https://github.com/bec-project/bec_widgets/commit/8b782ac302b4ccbfe768c066c3c9fbe31fdace75))
|
||||
|
||||
- **widget_io**: Widget hierarchy can grap all bec connectors from the widget recursively
|
||||
([`db83576`](https://github.com/bec-project/bec_widgets/commit/db83576346980eef59b5366bc07258edcbf6333b))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- **heatmap**: Thread worker optimization
|
||||
([`f98a5de`](https://github.com/bec-project/bec_widgets/commit/f98a5de7e9f154e6e9fc65a257776c9dec74eb84))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Add extra tour steps, add enter button
|
||||
([`2826919`](https://github.com/bec-project/bec_widgets/commit/2826919c5a330e2ba9666cfec1f9561b4cfd4bcf))
|
||||
|
||||
- Global refactoring to use device-signal pair names
|
||||
([`b93fbc5`](https://github.com/bec-project/bec_widgets/commit/b93fbc5cd31dbaa1bf4b18b9d30e3463ea539f72))
|
||||
|
||||
- Improvements to enum access
|
||||
([`19b7310`](https://github.com/bec-project/bec_widgets/commit/19b73104337a100cef39936dd7ec5c32c346f99b))
|
||||
|
||||
- **advanced_dock_area**: Change remove_widget to delete
|
||||
([`eda30e3`](https://github.com/bec-project/bec_widgets/commit/eda30e31396ec1e34c13be047564de334d9a5c6f))
|
||||
|
||||
- **bec_main_window**: Main app theme renamed to View
|
||||
([`37bfad7`](https://github.com/bec-project/bec_widgets/commit/37bfad7174982f7c3489e38cf715615719b34862))
|
||||
|
||||
- **busy-loader**: Refactor busy loader to use custom widget
|
||||
([`332ca20`](https://github.com/bec-project/bec_widgets/commit/332ca205c12c445513472a25366699e870e5a879))
|
||||
|
||||
- **busy-loager**: Improve eventFilter to avoid crashs if target or overlay is None.
|
||||
([`229da62`](https://github.com/bec-project/bec_widgets/commit/229da6244ae2bb2521ff0257db1772e5cceeee59))
|
||||
|
||||
- **developer_view**: Changed to use DockAreaWidget
|
||||
([`4d40918`](https://github.com/bec-project/bec_widgets/commit/4d40918b7c84c833d46287fec365d1810683adec))
|
||||
|
||||
- **developer_widget**: Enhance documentation and add missing imports
|
||||
([`5e0c376`](https://github.com/bec-project/bec_widgets/commit/5e0c3767742bcac8b39972d0972db0580c1863cd))
|
||||
|
||||
- **device-form-dialog**: Use native QDialogButtonBox instead of GroupBox layout
|
||||
([`12b4d3a`](https://github.com/bec-project/bec_widgets/commit/12b4d3a9e0ffe0539d5884bbedf4f14349a5e117))
|
||||
|
||||
- **dock_area**: Change name to BECDockArea
|
||||
([`71ed2d3`](https://github.com/bec-project/bec_widgets/commit/71ed2d353acc0e68eaef1fa55474db0b8e1f1eb9))
|
||||
|
||||
- **guided-tour**: Add support for QTableWidgetItem
|
||||
([`83489b7`](https://github.com/bec-project/bec_widgets/commit/83489b7519f41b75f2d3f2cdcf31b0075e41d52d))
|
||||
|
||||
- **main_app**: Adapted for DockAreaWidget changes
|
||||
([`ac850ec`](https://github.com/bec-project/bec_widgets/commit/ac850ec650695c12a77e0e8e598094d740312a89))
|
||||
|
||||
- **main_app**: Simpler id and object name management
|
||||
([`654aeb7`](https://github.com/bec-project/bec_widgets/commit/654aeb711626f0f85d288cd3c0a85d69ad2826d8))
|
||||
|
||||
- **monaco_dock**: Changed to use DockAreaWidget
|
||||
([`ed0d34a`](https://github.com/bec-project/bec_widgets/commit/ed0d34a60f8348a970da71d77801154ea70c24c6))
|
||||
|
||||
- **ophyd-validation**: Allow option to keep device visible after successful validation
|
||||
([`89d5c5a`](https://github.com/bec-project/bec_widgets/commit/89d5c5abdb0081e29d2c31ae6ded75a3f9abe0ff))
|
||||
|
||||
- **widget_io**: Hierarchy logic generalized
|
||||
([`00bf01c`](https://github.com/bec-project/bec_widgets/commit/00bf01c1290c4ead6d8270942fbfda2cbd7e9873))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix test
|
||||
([`de835e8`](https://github.com/bec-project/bec_widgets/commit/de835e81d8cf0ec6d3bca9d07ac21d4737666e31))
|
||||
|
||||
- **config-communicator**: Add test for cancel action
|
||||
([`24701c2`](https://github.com/bec-project/bec_widgets/commit/24701c2a270520de739e4615d0f52a6386bbadc0))
|
||||
|
||||
- **device-form-dialog**: Adapt tests
|
||||
([`f827e77`](https://github.com/bec-project/bec_widgets/commit/f827e77e870109b21e10b4cc28d6c09b8f77b2a6))
|
||||
|
||||
- **device-manager**: Use mocked client for tests
|
||||
([`836fedd`](https://github.com/bec-project/bec_widgets/commit/836fedd50e4fdb66bd7614a55c8e0f95a14c3fac))
|
||||
|
||||
- **device-manager-view**: Improve test coverage for device-manager-view
|
||||
([`4edc571`](https://github.com/bec-project/bec_widgets/commit/4edc57158be30d2500ad04d1b015bc8627cfb873))
|
||||
|
||||
- **e2e**: Raise with widget name
|
||||
([`3f76ade`](https://github.com/bec-project/bec_widgets/commit/3f76ade6289a75b76d7a5f67e9d72175378bedbe))
|
||||
|
||||
- **script_tree**: Improve hover event handling with waitUntil
|
||||
([`6296055`](https://github.com/bec-project/bec_widgets/commit/6296055c664070b8caeffda3c7047774bd692691))
|
||||
|
||||
- **widget_io**: Add dedicated unit tests for iter_widget_tree and helper methods
|
||||
([`041afc6`](https://github.com/bec-project/bec_widgets/commit/041afc68b1c7202a4609149e6f0e212fca629c87))
|
||||
|
||||
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||
|
||||
|
||||
## v2.45.14 (2026-01-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec_status**: Adjust bec status widget to info and version signature
|
||||
([`709ffd6`](https://github.com/bec-project/bec_widgets/commit/709ffd6927dceb903cbd0797fc162e56aef378c1))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Use auth.token instead of login_or_token
|
||||
([`0349c87`](https://github.com/bec-project/bec_widgets/commit/0349c872612ab0506e5662b813e78200a76d7590))
|
||||
|
||||
### Testing
|
||||
|
||||
- **device config**: Validate against pydantic
|
||||
([`de8fe3b`](https://github.com/bec-project/bec_widgets/commit/de8fe3b5f503ace17b0064d2ce9f54662b0fb77e))
|
||||
|
||||
- **scan control**: Avoid strict length comparisons
|
||||
([`d577fac`](https://github.com/bec-project/bec_widgets/commit/d577fac02fed11b2b1c44704c04fd111c2fed1d3))
|
||||
|
||||
|
||||
## v2.45.13 (2025-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
def dock_area(
|
||||
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
|
||||
) -> BECDockArea:
|
||||
"""
|
||||
Create an advanced dock area using Qt Advanced Docking System.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the advanced dock area.
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the workspace:
|
||||
- None: start empty
|
||||
- "restore": restore last used profile
|
||||
- "skip": do not initialize profile state
|
||||
- "<name>": load specific profile
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created advanced dock area.
|
||||
|
||||
"""
|
||||
|
||||
widget = BECDockArea(
|
||||
object_name=object_name,
|
||||
root_widget=True,
|
||||
profile_namespace="bec",
|
||||
startup_profile=startup_profile,
|
||||
)
|
||||
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
|
||||
return widget
|
||||
def dock_area(object_name: str | None = None) -> BECDockArea:
|
||||
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
|
||||
return _dock_area
|
||||
|
||||
|
||||
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
|
||||
|
||||
@@ -27,12 +27,10 @@ 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.screen_utils import apply_window_geometry, centered_geometry_for_app
|
||||
from bec_widgets.utils.toolbars.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_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import get_last_profile, list_profiles
|
||||
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.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
@@ -43,7 +41,6 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
@@ -77,28 +74,23 @@ class LaunchTile(RoundedFrame):
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setRenderHints(QPainter.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(
|
||||
size,
|
||||
size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Top label
|
||||
self.top_label = QLabel(top_label.upper())
|
||||
font_top = self.top_label.font()
|
||||
font_top.setPointSize(10)
|
||||
self.top_label.setFont(font_top)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
@@ -108,7 +100,7 @@ class LaunchTile(RoundedFrame):
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
@@ -124,13 +116,13 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
@@ -140,14 +132,13 @@ class LaunchTile(RoundedFrame):
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(
|
||||
0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet("""
|
||||
self.action_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
@@ -159,8 +150,9 @@ class LaunchTile(RoundedFrame):
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
""")
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
@@ -183,25 +175,16 @@ class LaunchTile(RoundedFrame):
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.ElideRight, max_width))
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
TILE_SIZE = (250, 300)
|
||||
DEFAULT_LAUNCH_SIZE = (800, 600)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
gui_id: str = None,
|
||||
window_title="BEC Launcher",
|
||||
launch_gui_class: str = None,
|
||||
launch_gui_id: str = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
@@ -215,7 +198,7 @@ class LaunchWindow(BECMainWindow):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
@@ -228,13 +211,11 @@ class LaunchWindow(BECMainWindow):
|
||||
name="dock_area",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Advanced Dock Area",
|
||||
description="Flexible application for managing modular widgets and user profiles.",
|
||||
action_button=self._open_dock_area,
|
||||
show_selector=True,
|
||||
selector_items=list_profiles("bec"),
|
||||
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._refresh_dock_area_profiles(preserve_selection=False)
|
||||
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
@@ -284,11 +265,6 @@ class LaunchWindow(BECMainWindow):
|
||||
self.register.callbacks.append(self._turn_off_the_lights)
|
||||
self.register.broadcast()
|
||||
|
||||
if launch_gui_class and launch_gui_id:
|
||||
# If a specific gui class is provided, launch it and hide the launcher
|
||||
self.launch(launch_gui_class, name=launch_gui_id)
|
||||
self.hide()
|
||||
|
||||
def register_tile(
|
||||
self,
|
||||
name: str,
|
||||
@@ -324,7 +300,7 @@ class LaunchWindow(BECMainWindow):
|
||||
)
|
||||
tile.setFixedWidth(self.TILE_SIZE[0])
|
||||
tile.setMinimumHeight(self.TILE_SIZE[1])
|
||||
tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
|
||||
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
|
||||
if action_button:
|
||||
tile.action_button.clicked.connect(action_button)
|
||||
if show_selector and selector_items:
|
||||
@@ -350,73 +326,6 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
self.tiles[name] = tile
|
||||
|
||||
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
|
||||
"""
|
||||
Refresh the dock-area profile selector, optionally preserving the selection.
|
||||
Defaults to Start Empty when no valid selection can be preserved.
|
||||
|
||||
Args:
|
||||
preserve_selection(bool): Whether to preserve the current selection or not.
|
||||
"""
|
||||
tile = self.tiles.get("dock_area")
|
||||
if tile is None or tile.selector is None:
|
||||
return
|
||||
|
||||
selector = tile.selector
|
||||
selected_text = (
|
||||
selector.currentText().strip() if preserve_selection and selector.count() > 0 else ""
|
||||
)
|
||||
|
||||
profiles = list_profiles("bec")
|
||||
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
|
||||
selector.blockSignals(True)
|
||||
selector.clear()
|
||||
for profile in selector_items:
|
||||
selector.addItem(profile)
|
||||
|
||||
if selected_text:
|
||||
# Try to preserve the current selection
|
||||
idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly)
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
else:
|
||||
# Selection no longer exists, fall back to default startup selection.
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
else:
|
||||
# No selection to preserve, use default startup selection.
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
selector.blockSignals(False)
|
||||
|
||||
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
|
||||
"""
|
||||
Set the selector default.
|
||||
|
||||
Preference order:
|
||||
1) Start Empty option (if available)
|
||||
2) Last used profile
|
||||
3) First available profile
|
||||
|
||||
Args:
|
||||
selector(QComboBox): The combobox to set.
|
||||
profiles(list[str]): List of available profiles.
|
||||
"""
|
||||
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
|
||||
if start_empty_idx >= 0:
|
||||
selector.setCurrentIndex(start_empty_idx)
|
||||
return
|
||||
|
||||
# Try to get last used profile
|
||||
last_profile = get_last_profile(namespace="bec")
|
||||
if last_profile and last_profile in profiles:
|
||||
idx = selector.findText(last_profile, Qt.MatchFlag.MatchExactly)
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
return
|
||||
|
||||
# If nothing else, select first item
|
||||
if selector.count() > 0:
|
||||
selector.setCurrentIndex(0)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_script: str,
|
||||
@@ -438,14 +347,14 @@ class LaunchWindow(BECMainWindow):
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
if geometry is None and launch_script != "custom_ui_file":
|
||||
geometry = self._default_launch_geometry()
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
# If name already exists, generate a unique one with counter suffix
|
||||
if name in existing_dock_areas:
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
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)
|
||||
@@ -463,31 +372,32 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update, geometry=geometry)
|
||||
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, geometry=geometry)
|
||||
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.")
|
||||
|
||||
result_widget = launch(name, **kwargs)
|
||||
result_widget = launch(name)
|
||||
result_widget.resize(result_widget.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if geometry is not None:
|
||||
result_widget.setGeometry(*geometry)
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
@@ -522,15 +432,13 @@ class LaunchWindow(BECMainWindow):
|
||||
window = BECMainWindow(object_name=filename)
|
||||
window.setCentralWidget(loaded)
|
||||
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
apply_window_geometry(window, None)
|
||||
window.show()
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(
|
||||
self, auto_update: str, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> AutoUpdates:
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
@@ -540,14 +448,12 @@ class LaunchWindow(BECMainWindow):
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(
|
||||
self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None
|
||||
) -> QWidget:
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
@@ -556,11 +462,11 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
QApplication.processEvents()
|
||||
|
||||
window.setCentralWidget(widget_instance)
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
@@ -585,21 +491,6 @@ class LaunchWindow(BECMainWindow):
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
def _open_dock_area(self):
|
||||
"""
|
||||
Open Advanced Dock Area using the selected profile.
|
||||
"""
|
||||
tile = self.tiles.get("dock_area")
|
||||
if tile is None or tile.selector is None:
|
||||
startup_profile = None
|
||||
else:
|
||||
selection = tile.selector.currentText().strip()
|
||||
if selection == START_EMPTY_PROFILE_OPTION:
|
||||
startup_profile = None
|
||||
else:
|
||||
startup_profile = selection if selection else None
|
||||
return self.launch("dock_area", startup_profile=startup_profile)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
Open a widget from the available widgets.
|
||||
@@ -611,10 +502,6 @@ class LaunchWindow(BECMainWindow):
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
def _default_launch_geometry(self) -> tuple[int, int, int, int] | None:
|
||||
width, height = self.DEFAULT_LAUNCH_SIZE
|
||||
return centered_geometry_for_app(width=width, height=height)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
@@ -651,7 +538,6 @@ class LaunchWindow(BECMainWindow):
|
||||
self.hide()
|
||||
|
||||
def showEvent(self, event):
|
||||
self._refresh_dock_area_profiles()
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
@@ -660,19 +546,10 @@ class LaunchWindow(BECMainWindow):
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
# get all parents of connections
|
||||
for connection in connections.values():
|
||||
try:
|
||||
parent = connection.parent()
|
||||
if parent is None and connection.objectName() != self.objectName():
|
||||
logger.info(
|
||||
f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting parent of connection: {e}")
|
||||
return False
|
||||
return True
|
||||
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):
|
||||
"""
|
||||
@@ -704,13 +581,10 @@ class LaunchWindow(BECMainWindow):
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
launcher = LaunchWindow()
|
||||
launcher.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QAction # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
@@ -7,22 +5,13 @@ 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.dock_area_view.dock_area_view import DockAreaView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.guided_tour import GuidedTour
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.screen_utils import (
|
||||
apply_centered_size,
|
||||
available_screen_geometry,
|
||||
main_app_size_for_screen,
|
||||
)
|
||||
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):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -54,49 +43,53 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
self._add_views()
|
||||
|
||||
# Initialize guided tour
|
||||
self.guided_tour = GuidedTour(self)
|
||||
self._setup_guided_tour()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.dock_area = DockAreaView(self)
|
||||
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) #TODO temporary disable until the bugs with BECShell are resolved
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
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",
|
||||
)
|
||||
# TODO temporary disable until the bugs with BECShell are resolved
|
||||
# self.add_view(
|
||||
# icon="code_blocks",
|
||||
# title="IDE",
|
||||
# widget=self.developer_view,
|
||||
# mini_text="IDE",
|
||||
# exclusive=True,
|
||||
# )
|
||||
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, view_id="waveform_view_popup", title="Waveform Plot"
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, view_id="waveform_view_stack", title="Waveform Plot"
|
||||
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",
|
||||
)
|
||||
@@ -104,9 +97,6 @@ class BECMainApp(BECMainWindow):
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# Add guided tour to Help menu
|
||||
self._add_guided_tour_to_menu()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
@@ -122,7 +112,7 @@ class BECMainApp(BECMainWindow):
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
view_id: str | None = None,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
@@ -136,8 +126,7 @@ class BECMainApp(BECMainWindow):
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
view_id(str, optional): Unique ID for the view/item. If omitted, uses mini_text;
|
||||
if mini_text is also omitted, uses title.
|
||||
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.
|
||||
@@ -150,11 +139,10 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
|
||||
"""
|
||||
resolved_id = sanitize_namespace(view_id or mini_text or title)
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=resolved_id,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
@@ -164,15 +152,13 @@ class BECMainApp(BECMainWindow):
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = resolved_id
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, view_id=resolved_id, title=title)
|
||||
|
||||
view_widget.change_object_name(resolved_id)
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[resolved_id] = idx
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
@@ -206,167 +192,8 @@ class BECMainApp(BECMainWindow):
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
def _setup_guided_tour(self):
|
||||
"""
|
||||
Setup the guided tour for the main application.
|
||||
Registers key UI components and delegates to views for their internal components.
|
||||
"""
|
||||
tour_steps = []
|
||||
|
||||
# --- General Layout Components ---
|
||||
|
||||
# Register the sidebar toggle button
|
||||
toggle_step = self.guided_tour.register_widget(
|
||||
widget=self.sidebar.toggle,
|
||||
title="Sidebar Toggle",
|
||||
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
|
||||
)
|
||||
tour_steps.append(toggle_step)
|
||||
|
||||
# Register the sidebar icons
|
||||
sidebar_dock_area = self.sidebar.components.get("dock_area")
|
||||
if sidebar_dock_area:
|
||||
dock_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_dock_area,
|
||||
title="Dock Area View",
|
||||
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
|
||||
)
|
||||
tour_steps.append(dock_step)
|
||||
|
||||
sidebar_device_manager = self.sidebar.components.get("device_manager")
|
||||
if sidebar_device_manager:
|
||||
device_manager_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_device_manager,
|
||||
title="Device Manager View",
|
||||
text="Click here to open the Device Manager view, where you can view and manage device configs.",
|
||||
)
|
||||
tour_steps.append(device_manager_step)
|
||||
|
||||
sidebar_developer_view = self.sidebar.components.get("developer_view")
|
||||
if sidebar_developer_view:
|
||||
developer_view_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_developer_view,
|
||||
title="Developer View",
|
||||
text="Click here to access the Developer view to write scripts and makros.",
|
||||
)
|
||||
tour_steps.append(developer_view_step)
|
||||
|
||||
# Register the dark mode toggle
|
||||
dark_mode_item = self.sidebar.components.get("dark_mode")
|
||||
if dark_mode_item:
|
||||
dark_mode_step = self.guided_tour.register_widget(
|
||||
widget=dark_mode_item,
|
||||
title="Theme Toggle",
|
||||
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
|
||||
)
|
||||
tour_steps.append(dark_mode_step)
|
||||
|
||||
# Register the client info label
|
||||
if hasattr(self, "_client_info_hover"):
|
||||
client_info_step = self.guided_tour.register_widget(
|
||||
widget=self._client_info_hover,
|
||||
title="Client Status",
|
||||
text="Displays status messages and information from the BEC Server.",
|
||||
)
|
||||
tour_steps.append(client_info_step)
|
||||
|
||||
# Register the scan progress bar if available
|
||||
if hasattr(self, "_scan_progress_hover"):
|
||||
progress_step = self.guided_tour.register_widget(
|
||||
widget=self._scan_progress_hover,
|
||||
title="Scan Progress",
|
||||
text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.",
|
||||
)
|
||||
tour_steps.append(progress_step)
|
||||
|
||||
# Register the notification indicator in the status bar
|
||||
if hasattr(self, "notification_indicator"):
|
||||
notif_step = self.guided_tour.register_widget(
|
||||
widget=self.notification_indicator,
|
||||
title="Notification Center",
|
||||
text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.",
|
||||
)
|
||||
tour_steps.append(notif_step)
|
||||
|
||||
# --- View-Specific Components ---
|
||||
|
||||
# Register all views that can extend the tour
|
||||
for view_id, view_index in self._view_index.items():
|
||||
view_widget = self.stack.widget(view_index)
|
||||
if not view_widget or not hasattr(view_widget, "register_tour_steps"):
|
||||
continue
|
||||
|
||||
# Get the view's tour steps
|
||||
view_tour = view_widget.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
if hasattr(view_widget.content, "register_tour_steps"):
|
||||
view_tour = view_widget.content.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
continue
|
||||
|
||||
# Get the corresponding sidebar navigation item
|
||||
nav_item = self.sidebar.components.get(view_id)
|
||||
if not nav_item:
|
||||
continue
|
||||
|
||||
# Use the view's title for the navigation button
|
||||
nav_step = self.guided_tour.register_widget(
|
||||
widget=nav_item,
|
||||
title=view_tour.view_title,
|
||||
text=f"Let's explore the features of the {view_tour.view_title}.",
|
||||
)
|
||||
tour_steps.append(nav_step)
|
||||
tour_steps.extend(view_tour.step_ids)
|
||||
|
||||
# Create the tour with all registered steps
|
||||
if tour_steps:
|
||||
self.guided_tour.create_tour(tour_steps)
|
||||
|
||||
def start_guided_tour(self):
|
||||
"""
|
||||
Public method to start the guided tour.
|
||||
This can be called programmatically or connected to a menu/button action.
|
||||
"""
|
||||
self.guided_tour.start_tour()
|
||||
|
||||
def _add_guided_tour_to_menu(self):
|
||||
"""
|
||||
Add a 'Guided Tour' action to the Help menu.
|
||||
"""
|
||||
|
||||
# Find the Help menu
|
||||
menu_bar = self.menuBar()
|
||||
help_menu = None
|
||||
for action in menu_bar.actions():
|
||||
if action.text() == "Help":
|
||||
help_menu = action.menu()
|
||||
break
|
||||
|
||||
if help_menu:
|
||||
# Add separator before the tour action
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Create and add the guided tour action
|
||||
tour_action = QAction("Start Guided Tour", self)
|
||||
tour_action.setIcon(material_icon("help"))
|
||||
tour_action.triggered.connect(self.start_guided_tour)
|
||||
tour_action.setShortcut("F1") # Add keyboard shortcut
|
||||
help_menu.addAction(tour_action)
|
||||
|
||||
def cleanup(self):
|
||||
for view_id, idx in self._view_index.items():
|
||||
view = self.stack.widget(idx)
|
||||
view.close()
|
||||
view.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""
|
||||
Main function to run the BEC main application, exposed as a script entry point through
|
||||
pyproject.toml.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
@@ -378,21 +205,23 @@ def main(): # pragma: no cover
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("BEC")
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
screen_geometry = available_screen_geometry()
|
||||
if screen_geometry is not None:
|
||||
width, height = main_app_size_for_screen(screen_geometry)
|
||||
apply_centered_size(w, width, height, available=screen_geometry)
|
||||
else:
|
||||
w.resize(w.minimumSizeHint())
|
||||
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())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
|
||||
@@ -127,10 +127,12 @@ class NavigationItem(QWidget):
|
||||
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("""
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
@@ -14,89 +14,13 @@ class DeveloperView(ViewBase):
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Developer View components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dev_widget = self.developer_widget
|
||||
|
||||
# IDE Toolbar
|
||||
def get_ide_toolbar():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.toolbar, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_toolbar,
|
||||
title="IDE Toolbar",
|
||||
text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Explorer
|
||||
def get_ide_explorer():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.explorer_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_explorer,
|
||||
title="File Explorer",
|
||||
text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Editor
|
||||
def get_ide_editor():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.monaco_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_editor,
|
||||
title="Code Editor",
|
||||
text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Console
|
||||
def get_ide_console():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.console_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_console,
|
||||
title="BEC Shell Console",
|
||||
text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Plotting Area
|
||||
def get_ide_plotting():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.plotting_ads, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_plotting,
|
||||
title="Plotting Area",
|
||||
text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
return ViewTourSteps(view_title="Developer View", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
@@ -126,11 +50,7 @@ if __name__ == "__main__":
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=developer_view,
|
||||
view_id="developer_view",
|
||||
exclusive=True,
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import markdown
|
||||
@@ -13,12 +11,11 @@ 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.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
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 BECShell, WebConsole
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
@@ -79,8 +76,6 @@ def markdown_to_html(md_text: str) -> str:
|
||||
|
||||
|
||||
class DeveloperWidget(DockAreaWidget):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||
@@ -93,15 +88,14 @@ class DeveloperWidget(DockAreaWidget):
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.explorer.setObjectName("Explorer")
|
||||
|
||||
self.console = BECShell(self, rpc_exposed=False)
|
||||
self.console.setObjectName("BEC Shell")
|
||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
||||
self.console = WebConsole(self)
|
||||
self.console.setObjectName("Console")
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = BECDockArea(
|
||||
self.plotting_ads = AdvancedDockArea(
|
||||
self,
|
||||
mode="plot",
|
||||
default_add_direction="bottom",
|
||||
@@ -130,7 +124,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
# 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.monaco.focused_editor.connect(self._on_focused_editor_changed)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
@@ -287,17 +280,14 @@ class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
"""Save the currently focused file in the Monaco editor."""
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
"""Toggle Vim mode in the Monaco editor."""
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
@@ -314,38 +304,22 @@ class DeveloperWidget(DockAreaWidget):
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
if widget.modified:
|
||||
# Save the file before execution if there are unsaved changes
|
||||
self.monaco.save_file()
|
||||
if widget.modified:
|
||||
# If still modified, user likely cancelled save dialog
|
||||
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):
|
||||
"""Stop the execution of the currently running script"""
|
||||
if not self.current_script_id:
|
||||
return
|
||||
self.console.send_ctrl_c()
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
"""Get the ID of the currently running script."""
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value: str | None):
|
||||
"""
|
||||
Set the ID of the currently running script.
|
||||
|
||||
Args:
|
||||
value (str | None): The script ID to set.
|
||||
Raises:
|
||||
ValueError: If the provided value is not a string or 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
|
||||
@@ -362,28 +336,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
|
||||
)
|
||||
|
||||
@SafeSlot(CDockWidget)
|
||||
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
|
||||
"""
|
||||
Disable the run / stop buttons if the focused editor is a macro file.
|
||||
Args:
|
||||
tab_widget: The currently focused tab widget in the Monaco editor.
|
||||
"""
|
||||
if not isinstance(tab_widget, CDockWidget):
|
||||
return
|
||||
widget = tab_widget.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
file_scope = widget.metadata.get("scope", "")
|
||||
run_action = self.toolbar.components.get_action("run")
|
||||
stop_action = self.toolbar.components.get_action("stop")
|
||||
if "macro" in file_scope:
|
||||
run_action.action.setEnabled(False)
|
||||
stop_action.action.setEnabled(False)
|
||||
else:
|
||||
run_action.action.setEnabled(True)
|
||||
stop_action.action.setEnabled(True)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
"""
|
||||
@@ -407,7 +359,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
widget.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources used by the developer widget."""
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Dialogs for device configuration forms and ophyd testing."""
|
||||
|
||||
from typing import Any, Iterable, Tuple
|
||||
|
||||
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
|
||||
@@ -22,7 +20,6 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
|
||||
)
|
||||
|
||||
DEFAULT_DEVICE = "CustomDevice"
|
||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -31,7 +28,7 @@ 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
|
||||
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
|
||||
@@ -50,11 +47,11 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
self.text_box.setReadOnly(True)
|
||||
layout.addWidget(self.text_box)
|
||||
|
||||
# Connect signal for validation messages
|
||||
|
||||
# Load and apply configuration
|
||||
config = config or {}
|
||||
device_name = config.get("name", None)
|
||||
if device_name:
|
||||
self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name)
|
||||
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)
|
||||
@@ -69,9 +66,6 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
self._resize_dialog()
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
# Add and test device config
|
||||
self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True)
|
||||
|
||||
def _resize_dialog(self):
|
||||
"""Resize the dialog based on the screen size."""
|
||||
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
|
||||
@@ -133,7 +127,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
# 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
|
||||
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 = ""
|
||||
@@ -176,17 +170,12 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.reset_btn = QtWidgets.QPushButton("Reset Form")
|
||||
|
||||
btn_box = QtWidgets.QDialogButtonBox(self)
|
||||
btn_box.addButton(self.cancel_btn, QtWidgets.QDialogButtonBox.ButtonRole.RejectRole)
|
||||
btn_box.addButton(self.reset_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
|
||||
btn_box.addButton(
|
||||
self.test_connection_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
|
||||
)
|
||||
btn_box.addButton(self.add_btn, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
for btn in btn_box.buttons():
|
||||
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)
|
||||
layout.addWidget(btn_box)
|
||||
|
||||
btn_layout.addWidget(btn)
|
||||
btn_box = QtWidgets.QGroupBox("Actions")
|
||||
btn_box.setLayout(btn_layout)
|
||||
frame_layout.addWidget(btn_box)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
@@ -199,17 +188,11 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
self.update_variant_combo(self._control_widgets["group_combo"].currentText())
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
# Wait dialog when adding config
|
||||
self._wait_dialog: QtWidgets.QProgressDialog | None = None
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
for widget in self._control_widgets.values():
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.close()
|
||||
self._wait_dialog.deleteLater()
|
||||
|
||||
@property
|
||||
def config_validation_result(self) -> tuple[dict, int, int, str]:
|
||||
@@ -285,132 +268,42 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE]
|
||||
)
|
||||
|
||||
def _create_validation_dialog(self) -> QtWidgets.QProgressDialog:
|
||||
"""
|
||||
Create and show a validation progress dialog while validating the device configuration.
|
||||
The dialog will be modal and prevent user interaction until validation is complete.
|
||||
"""
|
||||
wait_dialog = QtWidgets.QProgressDialog(
|
||||
"Validating config... please wait", None, 0, 0, parent=self
|
||||
)
|
||||
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
wait_dialog.setCancelButton(None)
|
||||
wait_dialog.setMinimumDuration(0)
|
||||
return wait_dialog
|
||||
|
||||
def _create_and_run_ophyd_validation(self, config: dict[str, Any]) -> OphydValidation:
|
||||
"""Run ophyd validation test on the current device configuration."""
|
||||
ophyd_validation = OphydValidation(parent=self)
|
||||
ophyd_validation.validation_completed.connect(self._handle_validation_result)
|
||||
ophyd_validation.multiple_validations_completed.connect(
|
||||
self._handle_devices_already_in_session_results
|
||||
)
|
||||
|
||||
# NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled
|
||||
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
|
||||
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
|
||||
# and therefore must not have the signal emitted immediately in the same event loop iteration.
|
||||
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
|
||||
QtCore.QTimer.singleShot(
|
||||
0, lambda: ophyd_validation.change_device_configs([config], True, False)
|
||||
)
|
||||
return ophyd_validation
|
||||
|
||||
@SafeSlot(list)
|
||||
def _handle_devices_already_in_session_results(
|
||||
self, validation_results: _ValidationResultIter
|
||||
) -> None:
|
||||
"""Handle completion if device is already in session."""
|
||||
if len(validation_results) != 1:
|
||||
logger.error(
|
||||
"Expected a single device validation result, but got multiple. Using first result."
|
||||
)
|
||||
result = validation_results[0] if len(validation_results) > 0 else None
|
||||
if result is None:
|
||||
logger.error(
|
||||
f"Received validation results: {validation_results} of unexpected length 0. Returning."
|
||||
)
|
||||
return
|
||||
device_config, config_status, connection_status, validation_msg = result
|
||||
self._handle_validation_result(
|
||||
device_config, config_status, connection_status, validation_msg
|
||||
)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _handle_validation_result(
|
||||
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
|
||||
):
|
||||
"""Handle completion of validation."""
|
||||
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(device_config)
|
||||
== DeviceModel.model_validate(self._validation_result[0])
|
||||
and connection_status == ConnectionStatus.UNKNOWN.value
|
||||
if DeviceModel.model_validate(config) == DeviceModel.model_validate(
|
||||
self._validation_result[0]
|
||||
):
|
||||
# Config unchanged, we can reuse previous connection status. Only do this if the new
|
||||
# connection status is UNKNOWN as the current validation should not test the connection.
|
||||
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: {device_config} compared to previous validation. Using status from recent validation."
|
||||
f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses."
|
||||
)
|
||||
self._validation_result = (device_config, config_status, connection_status, validation_msg)
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.accept()
|
||||
self._wait_dialog.close()
|
||||
self._wait_dialog.deleteLater()
|
||||
self._wait_dialog = None
|
||||
|
||||
def _add_config(self):
|
||||
"""
|
||||
Adding a config will always run a validation check of the config without a connection test.
|
||||
We will check if tests have already run, and reuse the information in case they also tested the connection to the device.
|
||||
"""
|
||||
config = self._device_config_template.get_config_fields()
|
||||
|
||||
# I. First we validate that the device name is valid, as this may create issues within the OphydValidation widget.
|
||||
# Validate device name first. If invalid, this should immediately block adding the device.
|
||||
if not validate_name(config.get("name", "")):
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Name",
|
||||
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
|
||||
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
|
||||
|
||||
# II. Next we will run the validation check of the config without connection test.
|
||||
# We will show a wait dialog while this is happening, and compare the results with the last known validation results.
|
||||
# If the config is unchanged, we will use the connection status results from the last validation.
|
||||
self._wait_dialog = self._create_validation_dialog()
|
||||
ophyd_validation: OphydValidation | None = None
|
||||
try:
|
||||
ophyd_validation = self._create_and_run_ophyd_validation(config)
|
||||
|
||||
# NOTE If dialog was already closed, this means that a validation callback was already received
|
||||
# which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above,
|
||||
# this should not happen, but we keep the check for safety.
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.exec() # This will block until the validation is complete
|
||||
|
||||
config, config_status, connection_status, validation_msg = self._validation_result
|
||||
|
||||
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{self._validation_result[3]}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
self.accepted_data.emit(
|
||||
config, config_status, connection_status, validation_msg, self._old_device_name
|
||||
)
|
||||
self.accept()
|
||||
finally:
|
||||
if ophyd_validation is not None:
|
||||
ophyd_validation.close()
|
||||
ophyd_validation.deleteLater()
|
||||
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)
|
||||
@@ -425,6 +318,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
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()
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, List, Tuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
@@ -12,17 +12,16 @@ 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
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
|
||||
_ValidationResultIter,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -235,18 +234,22 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
class UploadAction(IntEnum):
|
||||
"""Enum for upload actions."""
|
||||
|
||||
CANCEL = QtWidgets.QDialog.DialogCode.Rejected
|
||||
OK = QtWidgets.QDialog.DialogCode.Accepted
|
||||
CONNECTION_TEST_REQUESTED = 999
|
||||
CANCEL = QtWidgets.QDialog.Rejected
|
||||
OK = QtWidgets.QDialog.Accepted
|
||||
|
||||
# Request ophyd validation for all untested device connections
|
||||
# list of device configs, added: bool, connect: bool
|
||||
request_ophyd_validation = QtCore.Signal(list, bool, bool)
|
||||
# Signal to trigger upload after confirmation
|
||||
upload_confirmed = QtCore.Signal(int)
|
||||
|
||||
def __init__(self, parent, device_configs: dict[str, Tuple[dict, int, int]] | None = None):
|
||||
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()
|
||||
@@ -264,9 +267,14 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
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]]):
|
||||
"""
|
||||
@@ -280,6 +288,18 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
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")
|
||||
@@ -327,6 +347,11 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
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
|
||||
@@ -473,7 +498,7 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
|
||||
@SafeSlot()
|
||||
def _validate_connections(self):
|
||||
"""Request validation of all untested connections. This will close the dialog."""
|
||||
"""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:
|
||||
@@ -482,8 +507,13 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
testable_devices.append(config)
|
||||
|
||||
if len(testable_devices) > 0:
|
||||
self.request_ophyd_validation.emit(testable_devices, True, True)
|
||||
self.done(self.UploadAction.CONNECTION_TEST_REQUESTED)
|
||||
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):
|
||||
@@ -513,8 +543,7 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
[
|
||||
detailed_text,
|
||||
"These devices may not be reachable and disabled BEC upon loading the config.",
|
||||
"Consider validating these connections before proceeding.\n\n",
|
||||
"Continue anyway?",
|
||||
"Consider validating these connections before.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.critical(
|
||||
@@ -582,40 +611,35 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
return
|
||||
self.update_device_status(device_config, config_status, connection_status)
|
||||
|
||||
@SafeSlot(list)
|
||||
def _multiple_updates_from_ophyd_device_tests(self, validation_results: _ValidationResultIter):
|
||||
"""
|
||||
Callback slot for receiving multiple validation result updates from the ophyd test widget.
|
||||
|
||||
Args:
|
||||
validation_results (list): List of tuples containing (device_config, config_status, connection_status, validation_msg).
|
||||
"""
|
||||
for cfg, cfg_status, conn_status, val_msg in validation_results:
|
||||
self.update_device_status(cfg, cfg_status, conn_status)
|
||||
self._update_ui()
|
||||
|
||||
@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
|
||||
self._update_device_configs(device_config, config_status, connection_status, "")
|
||||
# Recalculate summaries and UI state
|
||||
self._update_ui()
|
||||
|
||||
def _update_device_configs(
|
||||
self,
|
||||
device_config: dict[str, Any],
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
validation_msg: str,
|
||||
):
|
||||
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)
|
||||
else:
|
||||
# If device not found, add it
|
||||
self.config_section.add_device(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
|
||||
@@ -681,7 +705,12 @@ def main(): # pragma: no cover
|
||||
]
|
||||
configs = {cfg[0]["name"]: cfg for cfg in sample_configs}
|
||||
apply_theme("dark")
|
||||
dialog = UploadRedisDialog(parent=None, device_configs=configs)
|
||||
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_())
|
||||
|
||||
@@ -2,29 +2,18 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, List, Literal, get_args
|
||||
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.callback_handler import EventType
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
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,
|
||||
@@ -33,12 +22,11 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs i
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
UploadRedisDialog,
|
||||
)
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
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.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
@@ -47,19 +35,11 @@ from bec_widgets.widgets.control.device_manager.components import (
|
||||
)
|
||||
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 (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
)
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -70,84 +50,6 @@ _yes_no_question = partial(
|
||||
)
|
||||
|
||||
|
||||
class CustomBusyWidget(QWidget):
|
||||
"""Custom busy widget to show during device config upload."""
|
||||
|
||||
cancel_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None, client: BECClient | None = None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Widgets
|
||||
self.progress = QWidget(parent=self)
|
||||
self.progress_layout = QVBoxLayout(self.progress)
|
||||
self.progress_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client)
|
||||
self.progress_layout.addWidget(self.progress_inner)
|
||||
self.progress.setMinimumWidth(320)
|
||||
|
||||
# Spinner
|
||||
self.spinner = SpinnerWidget(parent=self)
|
||||
scale = self._ui_scale()
|
||||
spinner_size = int(scale * 0.12) if scale else 1
|
||||
spinner_size = max(32, min(spinner_size, 96))
|
||||
self.spinner.setFixedSize(spinner_size, spinner_size)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = QPushButton("Cancel Upload", parent=self)
|
||||
self.cancel_button.setIcon(material_icon("cancel"))
|
||||
self.cancel_button.clicked.connect(self.cancel_requested.emit)
|
||||
button_height = int(spinner_size * 0.9)
|
||||
button_height = max(36, min(button_height, 72))
|
||||
aspect_ratio = 3.8 # width / height, visually stable for text buttons
|
||||
button_width = int(button_height * aspect_ratio)
|
||||
self.cancel_button.setFixedSize(button_width, button_height)
|
||||
color = get_accent_colors()
|
||||
self.cancel_button.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color.emergency.name()};
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
""")
|
||||
|
||||
# Layout
|
||||
content_layout = QVBoxLayout(self)
|
||||
content_layout.setContentsMargins(24, 24, 24, 24)
|
||||
content_layout.setSpacing(16)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
if hasattr(color, "_colors"):
|
||||
bg_color = color._colors.get("BG", None)
|
||||
if bg_color is None: # Fallback if missing
|
||||
bg_color = QColor(50, 50, 50, 255)
|
||||
self.setStyleSheet(f"""
|
||||
background-color: {bg_color.name()};
|
||||
border-radius: 12px;
|
||||
""")
|
||||
|
||||
def _ui_scale(self) -> int:
|
||||
parent = self.parent()
|
||||
if not parent:
|
||||
return 0
|
||||
return min(parent.width(), parent.height())
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
self.spinner.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
self.spinner.stop()
|
||||
|
||||
|
||||
class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
|
||||
|
||||
@@ -155,23 +57,13 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
|
||||
request_ophyd_validation = Signal(list, bool, bool)
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
def __init__(self, parent=None, client=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", *args, **kwargs)
|
||||
|
||||
# State variable for config upload
|
||||
self._config_upload_active: bool = False
|
||||
self._config_in_sync: bool = False
|
||||
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
|
||||
initial_status = scan_status.status if scan_status is not None else "closed"
|
||||
self._scan_is_running: bool = initial_status in ["open", "paused"]
|
||||
|
||||
# Push to Redis dialog
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||
# will block if we use the config_helper from self.client.config._config_helper
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
@@ -215,62 +107,23 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.device_table_config_changed,),
|
||||
(self.ophyd_test_view.change_device_configs,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_config_in_sync_with_redis,
|
||||
(self._update_config_in_sync,),
|
||||
(self._update_config_enabled_button,),
|
||||
),
|
||||
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.SCAN_STATUS, self._update_scan_running
|
||||
)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
# Build dock layout using shared helpers
|
||||
self._build_docks()
|
||||
|
||||
def cleanup(self):
|
||||
self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""If config upload is active when application is exiting, cancel it."""
|
||||
logger.info("Application is quitting, checking for active config upload...")
|
||||
if self._config_upload_active:
|
||||
logger.info("Application is quitting, cancelling active config upload...")
|
||||
self._config_helper.send_config_request(
|
||||
action="cancel", config=None, wait_for_response=True, timeout_s=10
|
||||
)
|
||||
logger.info("Config upload cancelled.")
|
||||
super().closeEvent(event)
|
||||
|
||||
##############################
|
||||
### Custom set busy widget ###
|
||||
##############################
|
||||
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""Create a custom busy state widget for uploading device configurations."""
|
||||
widget = CustomBusyWidget(parent=self, client=self.client)
|
||||
widget.cancel_requested.connect(self._cancel_device_config_upload)
|
||||
return widget
|
||||
|
||||
def _set_busy_wrapper(self, enabled: bool):
|
||||
"""Thin wrapper around set_busy to flip the state variable."""
|
||||
self._busy_overlay.set_opacity(0.92)
|
||||
self._config_upload_active = enabled
|
||||
self.set_busy(enabled=enabled)
|
||||
|
||||
##############################
|
||||
### Toolbar and Dock setup ###
|
||||
##############################
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
@@ -452,36 +305,6 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
######################################
|
||||
### Update button state management ###
|
||||
######################################
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_scan_running(self, scan_info: dict, _: dict):
|
||||
"""disable editing when scans are running and enable editing when they are finished"""
|
||||
msg = ScanStatusMessage.model_validate(scan_info)
|
||||
self._scan_is_running = msg.status in ["open", "paused"]
|
||||
self._update_config_enabled_button()
|
||||
|
||||
def _update_config_in_sync(self, in_sync: bool):
|
||||
self._config_in_sync = in_sync
|
||||
self._update_config_enabled_button()
|
||||
|
||||
def _update_config_enabled_button(self):
|
||||
action = self.toolbar.components.get_action("update_config_redis")
|
||||
enabled = not self._config_in_sync and not self._scan_is_running
|
||||
action.action.setEnabled(enabled)
|
||||
if enabled: # button is enabled
|
||||
action.action.setToolTip("Push current config to BEC Server")
|
||||
elif self._scan_is_running:
|
||||
action.action.setToolTip("Scan is currently running, config updates disabled.")
|
||||
else:
|
||||
action.action.setToolTip("Current config is in sync with BEC Server, updates disabled.")
|
||||
|
||||
#######################
|
||||
### Action Handlers ###
|
||||
#######################
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _run_validate_connection(self, connect: bool = True):
|
||||
@@ -489,15 +312,16 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
configs = list(self.device_table_view.get_selected_device_configs())
|
||||
if not configs:
|
||||
configs = self.device_table_view.get_device_config()
|
||||
# Adjust the state of the icons in the device table view
|
||||
self.device_table_view.update_multiple_device_validations(
|
||||
[
|
||||
(cfg, ConfigStatus.UNKNOWN.value, ConnectionStatus.UNKNOWN.value, "")
|
||||
for cfg in configs
|
||||
]
|
||||
)
|
||||
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."""
|
||||
@@ -600,8 +424,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"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()
|
||||
@@ -648,10 +474,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
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
|
||||
)
|
||||
self._upload_redis_dialog.request_ophyd_validation.connect(
|
||||
self.request_ophyd_validation.emit
|
||||
parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view
|
||||
)
|
||||
|
||||
# Show dialog
|
||||
@@ -661,10 +484,6 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
self._push_composition_to_redis(action="set")
|
||||
elif reply == UploadRedisDialog.UploadAction.CANCEL:
|
||||
self.ophyd_test_view.cancel_all_validations()
|
||||
elif reply == UploadRedisDialog.UploadAction.CONNECTION_TEST_REQUESTED:
|
||||
return QMessageBox.information(
|
||||
self, "Connection Test Requested", "Running connection test on untested devices."
|
||||
)
|
||||
|
||||
def _push_composition_to_redis(self, action: ConfigAction):
|
||||
"""Push the current device composition to Redis."""
|
||||
@@ -677,37 +496,12 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
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_wrapper(enabled=True)
|
||||
|
||||
def _cancel_device_config_upload(self):
|
||||
"""Cancel the device configuration upload process."""
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel")
|
||||
# Cancelling will raise an exception in the communicator, so we connect to the failure handler
|
||||
comm.signals.error.connect(self._handle_cancel_config_upload_failed)
|
||||
threadpool.start(comm)
|
||||
|
||||
def _handle_cancel_config_upload_failed(self, exception: Exception):
|
||||
"""Handle failure to cancel the config upload."""
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
devices_to_update = []
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
devices_to_update.append(
|
||||
(config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled")
|
||||
)
|
||||
# Rerun validation of all devices after cancellation
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
self.ophyd_test_view.change_device_configs(
|
||||
[cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False
|
||||
)
|
||||
# Config is in sync with BEC, so we update the state
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(False)
|
||||
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_wrapper(enabled=False)
|
||||
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."""
|
||||
@@ -716,7 +510,22 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"Error Uploading Config",
|
||||
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
|
||||
)
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
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):
|
||||
@@ -782,7 +591,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
):
|
||||
if old_device_name and old_device_name != data.get("name", ""):
|
||||
self.device_table_view.remove_device(old_device_name)
|
||||
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
|
||||
self.device_table_view.update_device_configs([data])
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
@@ -793,15 +602,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.device_table_view.update_device_configs([data], skip_validation=False)
|
||||
else: # Connection status was tested in dialog
|
||||
# If device is connected, we remove it from the ophyd validation view
|
||||
self.device_table_view.update_device_configs([data], skip_validation=True)
|
||||
# Update validation status in device table view and ophyd validation view
|
||||
self.ophyd_test_view._on_device_test_completed(
|
||||
data, config_status, connection_status, msg
|
||||
)
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Module for Device Manager View."""
|
||||
|
||||
from qtpy.QtCore import QRect
|
||||
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, ViewTourSteps
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
@@ -20,21 +19,11 @@ class DeviceManagerView(ViewBase):
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
content=content,
|
||||
view_id=view_id,
|
||||
title=title,
|
||||
rpc_passthrough_children=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.device_manager_widget = DeviceManagerWidget(
|
||||
parent=self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
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()
|
||||
@@ -45,110 +34,6 @@ class DeviceManagerView(ViewBase):
|
||||
"""
|
||||
self.device_manager_widget.on_enter()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Device Manager components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dm_widget = self.device_manager_widget
|
||||
|
||||
# The device_manager_widget is not yet initialized, so we will register
|
||||
# tour steps for its uninitialized state.
|
||||
|
||||
# Register Load Current Config button
|
||||
def get_load_current():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_current_config, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_current,
|
||||
title="Load Current Config",
|
||||
text="Load the current device configuration from the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# Register Load Config From File button
|
||||
def get_load_file():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_config_from_file, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_file,
|
||||
title="Load Config From File",
|
||||
text="Load a device configuration from a YAML file on disk.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
## Register steps for the initialized state
|
||||
# Register main device table
|
||||
def get_device_table():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
return (dm_widget.device_manager_display.device_table_view, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_device_table,
|
||||
title="Device Table",
|
||||
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
col_text_mapping = {
|
||||
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
|
||||
1: "Shows if a device is connectable. Validated on demand.",
|
||||
2: "Device name, unique across all devices within a config.",
|
||||
3: "Device class used to initialize the device on the BEC server.",
|
||||
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', 'async', 'continuous' or 'on_demand'.",
|
||||
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
|
||||
6: "User-defined tags associated with the device.",
|
||||
7: "A brief description of the device.",
|
||||
8: "Device is enabled when the configuration is loaded.",
|
||||
9: "Device is set to read-only.",
|
||||
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
|
||||
}
|
||||
|
||||
# We have at least one device registered
|
||||
def get_device_table_row(column: int):
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
table = dm_widget.device_manager_display.device_table_view.table
|
||||
header = table.horizontalHeader()
|
||||
x = header.sectionViewportPosition(column)
|
||||
table.horizontalScrollBar().setValue(x)
|
||||
# Recompute after scrolling
|
||||
x = header.sectionViewportPosition(column)
|
||||
w = header.sectionSize(column)
|
||||
h = header.height()
|
||||
rect = QRect(x, 0, w, h)
|
||||
top_left = header.viewport().mapTo(main_app, rect.topLeft())
|
||||
|
||||
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
|
||||
|
||||
for col, text in col_text_mapping.items():
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=lambda col=col: get_device_table_row(col),
|
||||
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
|
||||
text=text,
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
if not step_ids:
|
||||
return None
|
||||
|
||||
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
@@ -180,7 +65,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
_app.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
view_id="device_manager",
|
||||
id="device_manager",
|
||||
widget=device_manager_view.device_manager_widget,
|
||||
mini_text="DM",
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
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)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockAreaView(ViewBase):
|
||||
"""
|
||||
Modular dock area view for arranging and managing multiple dockable widgets.
|
||||
"""
|
||||
|
||||
RPC_CONTENT_CLASS = BECDockArea
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
self.dock_area = BECDockArea(
|
||||
self,
|
||||
profile_namespace="bec",
|
||||
auto_profile_namespace=False,
|
||||
object_name="DockArea",
|
||||
rpc_exposed=False,
|
||||
)
|
||||
self.set_content(self.dock_area)
|
||||
@@ -2,8 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -12,31 +11,55 @@ from qtpy.QtWidgets import (
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
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
|
||||
|
||||
|
||||
class ViewTourSteps(BaseModel):
|
||||
"""Model representing tour steps for a view.
|
||||
|
||||
Attributes:
|
||||
view_title: The human-readable title of the view.
|
||||
step_ids: List of registered step IDs in the order they should appear.
|
||||
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.
|
||||
"""
|
||||
|
||||
view_title: str
|
||||
step_ids: List[str]
|
||||
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(BECWidget, QWidget):
|
||||
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.
|
||||
@@ -44,28 +67,21 @@ class ViewBase(BECWidget, QWidget):
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
view_id (str | None): Optional view view_id, useful for debugging or introspection.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = ["activate"]
|
||||
RPC_CONTENT_CLASS: type[QWidget] | None = None
|
||||
RPC_CONTENT_ATTR = "content"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = view_id
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
@@ -99,40 +115,67 @@ class ViewBase(BECWidget, QWidget):
|
||||
"""
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def activate(self) -> None:
|
||||
"""Switch the parent application to this view."""
|
||||
if not self.view_id:
|
||||
raise ValueError("Cannot switch view without a view_id.")
|
||||
####### 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.
|
||||
|
||||
parent = self.parent()
|
||||
while parent is not None:
|
||||
if hasattr(parent, "set_current"):
|
||||
parent.set_current(self.view_id)
|
||||
return
|
||||
parent = parent.parent()
|
||||
raise RuntimeError("Could not find a parent application with set_current().")
|
||||
|
||||
def cleanup(self):
|
||||
if self.content is not None:
|
||||
self.content.close()
|
||||
self.content.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register this view's components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: A model containing the view title and step IDs,
|
||||
or None if this view has no tour steps.
|
||||
|
||||
Override this method in subclasses to register view-specific components.
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
return None
|
||||
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)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
@@ -160,17 +203,17 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.signal_edit = SignalComboBox(parent=self)
|
||||
self.signal_edit.include_config_signals = False
|
||||
self.signal_edit.insertItem(0, "")
|
||||
self.signal_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.signal_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.signal_edit.reset_selection)
|
||||
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.signal_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
@@ -182,7 +225,7 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
device_y=self.device_edit.currentText(), signal_y=self.signal_edit.currentText()
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -307,7 +350,7 @@ class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(device_y=dev, signal_y=sig)
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -297,119 +297,38 @@ class BECGuiClient(RPCBase):
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
|
||||
"""
|
||||
Apply a GUI theme or toggle between dark and light.
|
||||
|
||||
Args:
|
||||
theme(Literal["light", "dark"] | None): Theme to apply. If None, the current
|
||||
theme is fetched from the GUI and toggled.
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self._start(wait=True)
|
||||
|
||||
with wait_for_server(self):
|
||||
if theme is None:
|
||||
current_theme = self.launcher._run_rpc("fetch_theme")
|
||||
next_theme = "light" if current_theme == "dark" else "dark"
|
||||
else:
|
||||
next_theme = theme
|
||||
self.launcher._run_rpc("change_theme", theme=next_theme)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
||||
**kwargs,
|
||||
) -> client.AdvancedDockArea:
|
||||
) -> client.BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
|
||||
launch_script(str): The launch script to use. Defaults to "dock_area".
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the dock area:
|
||||
- None: start in transient empty workspace
|
||||
- "restore": restore last-used profile
|
||||
- "skip": skip profile initialization
|
||||
- "<name>": load the named profile
|
||||
**kwargs: Additional keyword arguments passed to the dock area.
|
||||
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
|
||||
Returns:
|
||||
client.AdvancedDockArea: The new dock area.
|
||||
|
||||
Examples:
|
||||
>>> gui.new() # Start with an empty unsaved workspace
|
||||
>>> gui.new(startup_profile="restore") # Restore last profile
|
||||
>>> gui.new(startup_profile="my_profile") # Load explicit profile
|
||||
client.BECDockArea: The new dock area.
|
||||
"""
|
||||
if "profile" in kwargs or "start_empty" in kwargs:
|
||||
raise TypeError(
|
||||
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
|
||||
)
|
||||
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _new_impl(
|
||||
self,
|
||||
*,
|
||||
name: str | None,
|
||||
geometry: tuple[int, int, int, int] | None,
|
||||
launch_script: str,
|
||||
startup_profile: str | Literal["restore", "skip"] | None,
|
||||
**kwargs,
|
||||
):
|
||||
if launch_script == "dock_area":
|
||||
try:
|
||||
return self.launcher._run_rpc(
|
||||
"system.launch_dock_area",
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
if (
|
||||
"Unknown system RPC method: system.launch_dock_area" not in error
|
||||
and "has no attribute 'system.launch_dock_area'" not in error
|
||||
):
|
||||
raise
|
||||
logger.debug("Server does not support system.launch_dock_area; using launcher RPC")
|
||||
|
||||
return self.launcher._run_rpc(
|
||||
"launch",
|
||||
launch_script=launch_script,
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area and its parent window.
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
@@ -417,19 +336,7 @@ class BECGuiClient(RPCBase):
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
|
||||
# Get the container_proxy (parent window) gui_id from the server registry
|
||||
obj = self._server_registry.get(widget._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {name} not found in registry.")
|
||||
|
||||
container_gui_id = obj.get("container_proxy")
|
||||
if container_gui_id:
|
||||
# Close the container window which will also clean up the dock area
|
||||
widget._run_rpc("close", gui_id=container_gui_id) # pylint: disable=protected-access
|
||||
else:
|
||||
# Fallback: just close the dock area directly
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
|
||||
@@ -164,13 +164,17 @@ class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
user_access_entries = self._get_user_access_entries(cls)
|
||||
if not user_access_entries:
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method_entry in user_access_entries:
|
||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
if obj is None:
|
||||
obj = getattr(cls, method.split(".setter")[0], None)
|
||||
is_property_setter = True
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
@@ -212,34 +216,6 @@ class {class_name}(RPCBase):"""
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
@staticmethod
|
||||
def _get_user_access_entries(cls) -> list[str]:
|
||||
entries = list(getattr(cls, "USER_ACCESS", []))
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
entries.extend(getattr(content_cls, "USER_ACCESS", []))
|
||||
return list(dict.fromkeys(entries))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_method_object(cls, method_entry: str):
|
||||
method_name = method_entry
|
||||
is_property_setter = False
|
||||
|
||||
if method_entry.endswith(".setter"):
|
||||
is_property_setter = True
|
||||
method_name = method_entry.split(".setter")[0]
|
||||
|
||||
candidate_classes = [cls]
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
candidate_classes.append(content_cls)
|
||||
|
||||
for candidate_cls in candidate_classes:
|
||||
obj = getattr(candidate_cls, method_name, None)
|
||||
if obj is not None:
|
||||
return method_name, obj, is_property_setter
|
||||
return method_name, None, is_property_setter
|
||||
|
||||
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
||||
"""
|
||||
Decorator to mark a method as an RPC call.
|
||||
@@ -315,8 +291,7 @@ def main():
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
|
||||
rpc_classes = get_custom_classes(module_name, packages=packages)
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
|
||||
@@ -292,11 +292,6 @@ class RPCBase:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
rpc_enabled = msg_result.get("__rpc__", True)
|
||||
if rpc_enabled is False:
|
||||
return None
|
||||
|
||||
msg_result = dict(msg_result)
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ from threading import RLock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -108,19 +109,11 @@ class RPCRegister:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = {}
|
||||
for gui_id, obj in self._rpc_register.items():
|
||||
try:
|
||||
if not shb.isValid(obj):
|
||||
continue
|
||||
connections[gui_id] = obj
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking validity of object {gui_id}: {e}")
|
||||
continue
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: type[BECWidget] | type[BECConnector]
|
||||
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ class RPCWidgetHandler:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
||||
+ get_all_plugin_widgets()
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
|
||||
@@ -8,7 +8,6 @@ import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
@@ -94,7 +93,6 @@ class GUIServer:
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
logger.info("Starting GUIServer", repr(self))
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
@@ -103,24 +101,22 @@ class GUIServer:
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.app.gui_server = self # type: ignore # make server accessible from QApplication for getattr in widgets
|
||||
self.setup_bec_icon()
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
|
||||
|
||||
if self.gui_class:
|
||||
self.launcher_window = LaunchWindow(
|
||||
gui_id=f"{self.gui_id}:launcher",
|
||||
launch_gui_class=self.gui_class,
|
||||
launch_gui_id=self.gui_class_id,
|
||||
)
|
||||
else:
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
if self.gui_class:
|
||||
# If the server is started with a specific gui class, we launch it.
|
||||
# This will automatically hide the launcher.
|
||||
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
@@ -129,7 +125,8 @@ class GUIServer:
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
self.shutdown()
|
||||
if self.app:
|
||||
self.app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
@@ -150,10 +147,9 @@ class GUIServer:
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def shutdown(self):
|
||||
logger.info("Shutdown GUIServer", repr(self))
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
|
||||
@@ -25,9 +25,11 @@ 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.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
@@ -364,6 +366,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# pylint: skip-file
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
@@ -26,16 +24,6 @@ class FakeDevice(BECDevice):
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
self.name: {
|
||||
"kind_str": "hinted",
|
||||
"component_name": self.name,
|
||||
"obj_name": self.name,
|
||||
"signal_class": "Signal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
@@ -220,9 +208,7 @@ class Device(FakeDevice):
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._service = args[0]
|
||||
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
@@ -269,17 +255,6 @@ class DMMock:
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
def _get_redis_device_config(self) -> list[dict]:
|
||||
"""Mock method to emulate DeviceManager._get_redis_device_config."""
|
||||
configs = []
|
||||
for device in self.devices.values():
|
||||
configs.append(device._config)
|
||||
return configs
|
||||
|
||||
def initialize(*_): ...
|
||||
|
||||
def shutdown(self): ...
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -8,21 +8,20 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||
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.name_utils import sanitize_namespace
|
||||
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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
else:
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -87,9 +86,8 @@ class BECConnector:
|
||||
config: ConnectionConfig | None = None,
|
||||
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,
|
||||
rpc_exposed: bool = True,
|
||||
rpc_passthrough_children: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -100,17 +98,12 @@ class BECConnector:
|
||||
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.
|
||||
rpc_exposed(bool, optional): If set to False, this instance is excluded from RPC registry broadcast and CLI namespace discovery.
|
||||
rpc_passthrough_children(bool, optional): Only relevant when ``rpc_exposed=False``.
|
||||
If True, RPC-visible children rebind to the next visible ancestor.
|
||||
If False (default), children stay hidden behind this widget.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
if object_name is not None:
|
||||
object_name = sanitize_namespace(object_name)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
parent = kwargs.pop("parent", None)
|
||||
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
|
||||
@@ -126,6 +119,7 @@ class BECConnector:
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
|
||||
self.rpc_register = RPCRegister()
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
@@ -133,13 +127,8 @@ class BECConnector:
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@SafeSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
app = QApplication.instance()
|
||||
gui_server = getattr(app, "gui_server", None)
|
||||
if gui_server and hasattr(gui_server, "shutdown"):
|
||||
gui_server.shutdown()
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
dispatcher.stop_cli_server()
|
||||
|
||||
try: # shutdown ophyd threads if any
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
@@ -195,54 +184,19 @@ class BECConnector:
|
||||
|
||||
# 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
|
||||
# If set to False, this instance is not exposed through RPC at all.
|
||||
self.rpc_exposed = bool(rpc_exposed)
|
||||
# If True on a hidden parent (rpc_exposed=False), children can bubble up to
|
||||
# the next visible RPC ancestor.
|
||||
self.rpc_passthrough_children = bool(rpc_passthrough_children)
|
||||
|
||||
self._update_object_name()
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = self._get_rpc_parent_ancestor()
|
||||
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 _get_rpc_parent_ancestor(self) -> BECConnector | None:
|
||||
"""
|
||||
Find the nearest ancestor that is RPC-addressable.
|
||||
|
||||
Rules:
|
||||
- If an ancestor has ``rpc_exposed=False``, it is an explicit visibility
|
||||
boundary unless ``rpc_passthrough_children=True``.
|
||||
- If an ancestor has ``RPC=False`` (but remains rpc_exposed), it is treated
|
||||
as structural and children continue to the next ancestor.
|
||||
- Lookup always happens through ``WidgetHierarchy.get_becwidget_ancestor``
|
||||
so plain ``QWidget`` nodes between connectors are ignored.
|
||||
"""
|
||||
current = self
|
||||
while True:
|
||||
parent = WidgetHierarchy.get_becwidget_ancestor(current)
|
||||
if parent is None:
|
||||
return None
|
||||
|
||||
if not getattr(parent, "rpc_exposed", True):
|
||||
if getattr(parent, "rpc_passthrough_children", False):
|
||||
current = parent
|
||||
continue
|
||||
return parent
|
||||
|
||||
if getattr(parent, "RPC", True):
|
||||
return parent
|
||||
|
||||
current = parent
|
||||
return None
|
||||
|
||||
def change_object_name(self, name: str) -> None:
|
||||
"""
|
||||
Change the object name of the widget. Unregister old name and register the new one.
|
||||
@@ -252,7 +206,7 @@ class BECConnector:
|
||||
"""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
|
||||
self._update_object_name()
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
"""
|
||||
@@ -261,13 +215,11 @@ class BECConnector:
|
||||
"""
|
||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC unless instance-level exposure is disabled.
|
||||
if getattr(self, "rpc_exposed", True):
|
||||
self.rpc_register.add_rpc(self)
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error emitting name_established signal: {e}")
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
@@ -278,20 +230,23 @@ 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.
|
||||
"""
|
||||
if not shb.isValid(self):
|
||||
return
|
||||
|
||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
|
||||
QApplication.sendPostedEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)]
|
||||
siblings = parent_bec.findChildren(BECConnector)
|
||||
else:
|
||||
# No parent => treat all top-level BECConnectors as siblings
|
||||
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
|
||||
connections = self.rpc_register.list_all_connections().values()
|
||||
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
|
||||
siblings = [w for w in all_bec if WidgetHierarchy.get_becwidget_ancestor(w) is None]
|
||||
# 1) Gather all BECConnectors from QApplication
|
||||
all_widgets = QApplication.allWidgets()
|
||||
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
|
||||
# 2) "Top-level" means closest BECConnector parent is None
|
||||
top_level_bec = [
|
||||
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
|
||||
]
|
||||
# 3) We are among these top-level siblings
|
||||
siblings = top_level_bec
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
@@ -319,8 +274,6 @@ class BECConnector:
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
|
||||
name = sanitize_namespace(name)
|
||||
super().setObjectName(name)
|
||||
self.object_name = name
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
@@ -503,8 +456,12 @@ class BECConnector:
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
# If the widget is attached to a dock, remove it from the dock.
|
||||
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
|
||||
if self._parent_dock is not None:
|
||||
self._parent_dock.delete(self.object_name)
|
||||
# If the widget is from Qt, trigger its close method.
|
||||
if hasattr(self, "close"):
|
||||
elif hasattr(self, "close"):
|
||||
self.close()
|
||||
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
|
||||
# i.e. Curve Item from Waveform
|
||||
@@ -528,62 +485,6 @@ class BECConnector:
|
||||
else:
|
||||
return self.config
|
||||
|
||||
def export_settings(self) -> dict:
|
||||
"""
|
||||
Export the settings of the widget as dict.
|
||||
|
||||
Returns:
|
||||
dict: The exported settings of the widget.
|
||||
"""
|
||||
|
||||
# We first get all qproperties that were defined in a bec_widgets class
|
||||
objs = self._get_bec_meta_objects()
|
||||
settings = {}
|
||||
for prop_name in objs.keys():
|
||||
try:
|
||||
prop_value = getattr(self, prop_name)
|
||||
settings[prop_name] = prop_value
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not export property '{prop_name}' from '{self.__class__.__name__}': {e}"
|
||||
)
|
||||
return settings
|
||||
|
||||
def load_settings(self, settings: dict) -> None:
|
||||
"""
|
||||
Load the settings of the widget from dict.
|
||||
|
||||
Args:
|
||||
settings (dict): The settings to load into the widget.
|
||||
"""
|
||||
objs = self._get_bec_meta_objects()
|
||||
for prop_name, prop_value in settings.items():
|
||||
if prop_name in objs:
|
||||
try:
|
||||
setattr(self, prop_name, prop_value)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not load property '{prop_name}' into '{self.__class__.__name__}': {e}"
|
||||
)
|
||||
|
||||
def _get_bec_meta_objects(self) -> dict:
|
||||
"""
|
||||
Get BEC meta objects for the widget.
|
||||
|
||||
Returns:
|
||||
dict: BEC meta objects.
|
||||
"""
|
||||
if not isinstance(self, QObject):
|
||||
return {}
|
||||
objects = {}
|
||||
for name, attr in vars(self.__class__).items():
|
||||
if isinstance(attr, Property):
|
||||
# Check if the property is a SafeProperty
|
||||
is_safe_property = getattr(attr.fget, "__is_safe_getter__", False)
|
||||
if is_safe_property:
|
||||
objects[name] = attr
|
||||
return objects
|
||||
|
||||
|
||||
# --- Example usage of BECConnector: running a simple task ---
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -123,16 +123,17 @@ class BECDispatcher:
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if client is None:
|
||||
if config is not None and not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClient(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
else:
|
||||
self.client = client
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
logger.info("Shutting down BECClient to switch to QtRedisConnector")
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Login dialog for user authentication.
|
||||
The Login Widget is styled in a Material Design style and emits
|
||||
the entered credentials through a signal for further processing.
|
||||
"""
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class BECLogin(QWidget):
|
||||
"""Login dialog for user authentication in Material Design style."""
|
||||
|
||||
credentials_entered = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
# Only displayed if this widget as standalone widget, and not embedded in another widget
|
||||
self.setWindowTitle("Login")
|
||||
|
||||
title = QLabel("Sign in", parent=self)
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet("""
|
||||
#QLabel
|
||||
{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
""")
|
||||
|
||||
self.username = QLineEdit(parent=self)
|
||||
self.username.setPlaceholderText("Username")
|
||||
|
||||
self.password = QLineEdit(parent=self)
|
||||
self.password.setPlaceholderText("Password")
|
||||
self.password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
|
||||
self.ok_btn = QPushButton("Sign in", parent=self)
|
||||
self.ok_btn.setDefault(True)
|
||||
self.ok_btn.clicked.connect(self._emit_credentials)
|
||||
# If the user presses Enter in the password field, trigger the OK button click
|
||||
self.password.returnPressed.connect(self.ok_btn.click)
|
||||
|
||||
# Build Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(32, 32, 32, 32)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(title)
|
||||
layout.addSpacing(8)
|
||||
layout.addWidget(self.username)
|
||||
layout.addWidget(self.password)
|
||||
layout.addSpacing(12)
|
||||
layout.addWidget(self.ok_btn)
|
||||
|
||||
self.username.setFocus()
|
||||
|
||||
self.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
def _clear_password(self):
|
||||
"""Clear the password field."""
|
||||
self.password.clear()
|
||||
|
||||
def _emit_credentials(self):
|
||||
"""Emit credentials and clear the password field."""
|
||||
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
|
||||
self._clear_password()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = BECLogin()
|
||||
|
||||
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -6,20 +6,17 @@ from typing import TYPE_CHECKING
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
|
||||
from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
|
||||
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.busy_loader import install_busy_loader
|
||||
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.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -41,6 +38,8 @@ class BECWidget(BECConnector):
|
||||
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
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -59,7 +58,9 @@ 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, **kwargs)
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
if theme_update:
|
||||
@@ -67,14 +68,18 @@ class BECWidget(BECConnector):
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay: "BusyLoaderOverlay" | None = None
|
||||
self._busy_state_widget: QWidget | None = None
|
||||
|
||||
self._busy_overlay = None
|
||||
self._loading = False
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
self._show_busy_overlay()
|
||||
self._loading = True
|
||||
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."""
|
||||
@@ -95,109 +100,48 @@ class BECWidget(BECConnector):
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""
|
||||
Method to create a custom busy state widget to be shown in the busy overlay.
|
||||
Child classes should overrid this method to provide a custom widget if desired.
|
||||
|
||||
Returns:
|
||||
QWidget: The custom busy state widget.
|
||||
|
||||
NOTE:
|
||||
The implementation here is a SpinnerWidget with a "Loading..." label. This is the default
|
||||
busy state widget for all BECWidgets. However, child classes with specific needs for the
|
||||
busy state can easily overrite this method to provide a custom widget. The signature of
|
||||
the method must be preserved to ensure compatibility with the busy overlay system. If
|
||||
the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up.
|
||||
|
||||
The widget may connect to the _busy_overlay signals foreground_color_changed and
|
||||
scrim_color_changed to update its colors when the theme changes.
|
||||
"""
|
||||
|
||||
# Widget
|
||||
class BusyStateWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# label
|
||||
label = QLabel("Loading...", self)
|
||||
label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
label.setFont(f)
|
||||
|
||||
# spinner
|
||||
spinner = SpinnerWidget(self)
|
||||
spinner.setFixedSize(42, 42)
|
||||
|
||||
# Layout
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
self.setLayout(lay)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.stop()
|
||||
|
||||
widget = BusyStateWidget(self)
|
||||
return widget
|
||||
|
||||
def _install_busy_loader(self) -> "BusyLoaderOverlay" | None:
|
||||
"""
|
||||
Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
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(target=self, start_loading=False)
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
|
||||
# Create and set the busy state widget
|
||||
self._busy_state_widget = self.create_busy_state_widget()
|
||||
self._busy_overlay.set_widget(self._busy_state_widget)
|
||||
return overlay
|
||||
|
||||
def _show_busy_overlay(self) -> None:
|
||||
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
|
||||
if self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
|
||||
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) -> None:
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
"""
|
||||
Set the busy state of the widget. This will show or hide the loading overlay, which will
|
||||
block user interaction with the widget and show the busy_state_widget if provided. Per
|
||||
default, the busy state widget is a spinner with "Loading..." text.
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the busy state.
|
||||
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 not yet installed, install the busy overlay now together with the busy state widget
|
||||
if self._busy_overlay is None:
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
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._show_busy_overlay()
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
@@ -211,6 +155,19 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
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)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
@@ -223,8 +180,8 @@ class BECWidget(BECConnector):
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None:
|
||||
overlay._update_palette()
|
||||
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}")
|
||||
|
||||
@@ -350,13 +307,10 @@ class BECWidget(BECConnector):
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
|
||||
overlay.cleanup()
|
||||
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."""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -14,10 +13,10 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
@@ -29,10 +28,6 @@ class _OverlayEventFilter(QObject):
|
||||
self._overlay = overlay
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if not hasattr(self, "_target") or self._target is None:
|
||||
return False
|
||||
if not hasattr(self, "_overlay") or self._overlay is None:
|
||||
return False
|
||||
if obj is self._target and event.type() in (
|
||||
QEvent.Resize,
|
||||
QEvent.Show,
|
||||
@@ -58,201 +53,132 @@ class BusyLoaderOverlay(QWidget):
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
foreground_color_changed = Signal(QColor)
|
||||
scrim_color_changed = Signal(QColor)
|
||||
|
||||
def __init__(self, parent: QWidget, opacity: float = 0.35, **kwargs):
|
||||
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._scrim_color = QColor(128, 128, 128, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self._filter: QObject | None = None
|
||||
|
||||
# Set Main Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(10)
|
||||
self.setLayout(layout)
|
||||
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)
|
||||
|
||||
# Custom widget placeholder
|
||||
self._custom_widget: QWidget | None = None
|
||||
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)
|
||||
|
||||
# Add a frame around the content
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._update_palette()
|
||||
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()
|
||||
|
||||
@SafeProperty(QColor, notify=scrim_color_changed)
|
||||
def scrim_color(self) -> QColor:
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
"""
|
||||
The overlay scrim color.
|
||||
"""
|
||||
return self._scrim_color
|
||||
|
||||
@scrim_color.setter
|
||||
def scrim_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
raise TypeError("scrim_color must be a QColor")
|
||||
self._scrim_color = value
|
||||
self.update()
|
||||
|
||||
@SafeProperty(QColor, notify=foreground_color_changed)
|
||||
def foreground_color(self) -> QColor:
|
||||
"""
|
||||
The overlay foreground color (text, spinner).
|
||||
"""
|
||||
return self._label_color
|
||||
|
||||
@foreground_color.setter
|
||||
def foreground_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
try:
|
||||
color = QColor(value)
|
||||
if not color.isValid():
|
||||
raise ValueError(f"Invalid color: {value}")
|
||||
except Exception:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise ValueError(f"Color {value} is invalid, cannot be converted to QColor")
|
||||
self._label_color = value
|
||||
self.update()
|
||||
|
||||
def set_filter(self, filt: _OverlayEventFilter):
|
||||
"""
|
||||
Set an event filter to keep the overlay sized and stacked over its target.
|
||||
Update the overlay text.
|
||||
|
||||
Args:
|
||||
filt(QObject): The event filter instance.
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
self._filter = filt
|
||||
target = filt._target
|
||||
if self.parent() != target:
|
||||
logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}")
|
||||
target.installEventFilter(self._filter)
|
||||
|
||||
######################
|
||||
### Public methods ###
|
||||
######################
|
||||
|
||||
def set_widget(self, widget: QWidget):
|
||||
"""
|
||||
Set a custom widget as an overlay for the busy overlay.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The custom widget to display.
|
||||
"""
|
||||
lay = self.layout()
|
||||
if lay is None:
|
||||
return
|
||||
self._custom_widget = widget
|
||||
lay.addWidget(widget, 0, Qt.AlignHCenter)
|
||||
self._label.setText(text)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a
|
||||
value outside this range is provided, it will be clamped.
|
||||
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
|
||||
base = self.scrim_color
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self.scrim_color = base
|
||||
self._update_palette()
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
|
||||
##########################
|
||||
### Internal methods ###
|
||||
##########################
|
||||
|
||||
def _update_palette(self):
|
||||
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]
|
||||
_bg = theme.color("BORDER")
|
||||
_fg = theme.color("FG")
|
||||
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
|
||||
_bg = QColor(30, 30, 30)
|
||||
_fg = QColor(230, 230, 230)
|
||||
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
# Semi-transparent scrim derived from bg
|
||||
base = _bg if isinstance(_bg, QColor) else QColor(str(_bg))
|
||||
base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self.scrim_color = base
|
||||
fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg))
|
||||
self.foreground_color = fg
|
||||
|
||||
# Set the frame style with updated foreground colors
|
||||
r, g, b, a = base.getRgb()
|
||||
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 {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}"
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
#############################
|
||||
### Custom Event Handlers ###
|
||||
#############################
|
||||
|
||||
# --- QWidget overrides ---
|
||||
def showEvent(self, e):
|
||||
# Call showEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.showEvent(e)
|
||||
self._spinner.start()
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
# Call hideEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.hideEvent(e)
|
||||
self._spinner.stop()
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
# Call resizeEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.resizeEvent(e)
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
# TODO should we have this cleanup here?
|
||||
def cleanup(self):
|
||||
"""Cleanup resources used by the overlay."""
|
||||
if self._custom_widget is not None:
|
||||
if hasattr(self._custom_widget, "cleanup"):
|
||||
self._custom_widget.cleanup()
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, start_loading: bool = False, opacity: float = 0.35
|
||||
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(parent=target, opacity=opacity)
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay))
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
@@ -261,63 +187,65 @@ def install_busy_loader(
|
||||
# --------------------------
|
||||
# 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
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None, start_busy: bool = False):
|
||||
super().__init__(parent=parent, theme_update=True, start_busy=start_busy)
|
||||
|
||||
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): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget(start_busy=True)
|
||||
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))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
|
||||
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)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from pydantic_core import PydanticCustomError
|
||||
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
@@ -52,103 +47,12 @@ def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
"""
|
||||
logger.info(f"Applying theme: {theme}")
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def list_available_colormaps() -> list[str]:
|
||||
"""
|
||||
List colormap names available via the pyqtgraph colormap registry.
|
||||
|
||||
Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus).
|
||||
"""
|
||||
|
||||
def _list(source: str | None = None) -> list[str]:
|
||||
try:
|
||||
return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source)
|
||||
except Exception: # pragma: no cover - backend may be missing
|
||||
return []
|
||||
|
||||
return [*_list(None), *_list("matplotlib"), *_list("colorcet")]
|
||||
|
||||
@staticmethod
|
||||
def list_available_gradient_presets() -> list[str]:
|
||||
"""
|
||||
List `GradientEditorItem` preset names (HistogramLUT right-click menu entries).
|
||||
"""
|
||||
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
|
||||
|
||||
return list(Gradients.keys())
|
||||
|
||||
@staticmethod
|
||||
def canonical_colormap_name(color_map: str) -> str:
|
||||
"""
|
||||
Return an available colormap/preset name if a case-insensitive match exists.
|
||||
"""
|
||||
requested = (color_map or "").strip()
|
||||
if not requested:
|
||||
return requested
|
||||
|
||||
registry = Colors.list_available_colormaps()
|
||||
presets = Colors.list_available_gradient_presets()
|
||||
available = set(registry) | set(presets)
|
||||
|
||||
if requested in available:
|
||||
return requested
|
||||
|
||||
# Case-insensitive match.
|
||||
requested_lc = requested.casefold()
|
||||
|
||||
for name in available:
|
||||
if name.casefold() == requested_lc:
|
||||
return name
|
||||
|
||||
return requested
|
||||
|
||||
@staticmethod
|
||||
def get_colormap(color_map: str) -> pg.ColorMap:
|
||||
"""
|
||||
Resolve a string into a `pg.ColorMap` using either:
|
||||
- the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or
|
||||
- `GradientEditorItem` presets (HistogramLUT right-click menu).
|
||||
"""
|
||||
name = Colors.canonical_colormap_name(color_map)
|
||||
if not name:
|
||||
raise ValueError("Empty colormap name")
|
||||
|
||||
return Colors._get_colormap_cached(name)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=256)
|
||||
def _get_colormap_cached(name: str) -> pg.ColorMap:
|
||||
# 1) Registry/backends
|
||||
try:
|
||||
cmap = pg.colormap.get(name)
|
||||
if cmap is not None:
|
||||
return cmap
|
||||
except Exception:
|
||||
pass
|
||||
for source in ("matplotlib", "colorcet"):
|
||||
try:
|
||||
cmap = pg.colormap.get(name, source=source)
|
||||
if cmap is not None:
|
||||
return cmap
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2) Presets -> ColorMap
|
||||
|
||||
if name not in Gradients:
|
||||
raise KeyError(f"Colormap '{name}' not found")
|
||||
|
||||
ge = pg.GradientEditorItem()
|
||||
ge.loadPreset(name)
|
||||
|
||||
return ge.colorMap()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
@@ -230,7 +134,7 @@ class Colors:
|
||||
if theme_offset < 0 or theme_offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
cmap = pg.colormap.get(colormap)
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions that are evenly spaced within the acceptable range
|
||||
@@ -278,7 +182,7 @@ class Colors:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
cmap = pg.colormap.get(colormap)
|
||||
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
|
||||
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
|
||||
|
||||
@@ -544,103 +448,18 @@ class Colors:
|
||||
Raises:
|
||||
PydanticCustomError: If colormap is invalid.
|
||||
"""
|
||||
normalized = Colors.canonical_colormap_name(color_map)
|
||||
try:
|
||||
Colors.get_colormap(normalized)
|
||||
except Exception as ext:
|
||||
logger.warning(f"Colormap validation error: {ext}")
|
||||
available_pg_maps = pg.colormap.listMaps()
|
||||
available_mpl_maps = pg.colormap.listMaps("matplotlib")
|
||||
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
|
||||
|
||||
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
|
||||
if color_map not in available_colormaps:
|
||||
if return_error:
|
||||
available_colormaps = sorted(
|
||||
set(Colors.list_available_colormaps())
|
||||
| set(Colors.list_available_gradient_presets())
|
||||
)
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
else:
|
||||
return False
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def relative_luminance(color: QColor) -> float:
|
||||
"""
|
||||
Calculate the relative luminance of a QColor according to WCAG 2.0 standards.
|
||||
See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance.
|
||||
|
||||
Args:
|
||||
color(QColor): The color to calculate the relative luminance for.
|
||||
|
||||
Returns:
|
||||
float: The relative luminance of the color.
|
||||
"""
|
||||
r = color.red() / 255.0
|
||||
g = color.green() / 255.0
|
||||
b = color.blue() / 255.0
|
||||
|
||||
def adjust(c):
|
||||
if c <= 0.03928:
|
||||
return c / 12.92
|
||||
return ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r = adjust(r)
|
||||
g = adjust(g)
|
||||
b = adjust(b)
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
@staticmethod
|
||||
def _tint_strength(
|
||||
accent: QColor, background: QColor, min_tint: float = 0.06, max_tint: float = 0.18
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the tint strength based on the contrast between the accent and background colors.
|
||||
min_tint and max_tint define the range of tint strength and are empirically chosen.
|
||||
|
||||
Args:
|
||||
accent(QColor): The accent color.
|
||||
background(QColor): The background color.
|
||||
min_tint(float): The minimum tint strength.
|
||||
max_tint(float): The maximum tint strength.
|
||||
|
||||
Returns:
|
||||
float: The tint strength between 0 and 1.
|
||||
"""
|
||||
l_accent = Colors.relative_luminance(accent)
|
||||
l_bg = Colors.relative_luminance(background)
|
||||
|
||||
contrast = abs(l_accent - l_bg)
|
||||
|
||||
# normalize contrast to a value between 0 and 1
|
||||
t = min(contrast / 0.9, 1.0)
|
||||
return min_tint + t * (max_tint - min_tint)
|
||||
|
||||
@staticmethod
|
||||
def _blend(background: QColor, accent: QColor, t: float) -> QColor:
|
||||
"""
|
||||
Blend two colors based on a tint strength t.
|
||||
"""
|
||||
return QColor(
|
||||
round(background.red() + (accent.red() - background.red()) * t),
|
||||
round(background.green() + (accent.green() - background.green()) * t),
|
||||
round(background.blue() + (accent.blue() - background.blue()) * t),
|
||||
round(background.alpha() + (accent.alpha() - background.alpha()) * t),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def subtle_background_color(accent: QColor, background: QColor) -> QColor:
|
||||
"""
|
||||
Generate a subtle, contrast-safe background color derived from an accent color.
|
||||
|
||||
Args:
|
||||
accent(QColor): The accent color.
|
||||
background(QColor): The background color.
|
||||
Returns:
|
||||
QColor: The generated subtle background color.
|
||||
"""
|
||||
if not accent.isValid() or not background.isValid():
|
||||
return background
|
||||
|
||||
tint = Colors._tint_strength(accent, background)
|
||||
return Colors._blend(background, accent, tint)
|
||||
return color_map
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import functools
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
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,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
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: Any = None,
|
||||
auto_emit: bool = False,
|
||||
emit_value: Literal["stored", "input"] | Callable[[object, object], object] = "stored",
|
||||
emit_on_change: bool = True,
|
||||
**prop_kwargs,
|
||||
):
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
"""
|
||||
Decorator to create a Qt Property with safe getter and setter so that
|
||||
Qt Designer won't crash if an exception occurs in either method.
|
||||
@@ -41,15 +22,7 @@ def SafeProperty(
|
||||
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
|
||||
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
|
||||
default: Any default/fallback value to return if the getter raises an exception.
|
||||
auto_emit (bool): If True, automatically emit property_changed signal when setter is called.
|
||||
Requires the widget to have a property_changed signal (str, object).
|
||||
Note: This is different from Qt's 'notify' parameter which expects a Signal.
|
||||
emit_value: Controls which value is emitted when auto_emit=True.
|
||||
- "stored" (default): emit the value from the getter after setter runs
|
||||
- "input": emit the raw setter input
|
||||
- callable: called as emit_value(self_, value) after setter and must return the value to emit
|
||||
emit_on_change (bool): If True, emit only when the stored value changes.
|
||||
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor (check https://doc.qt.io/qt-6/properties.html).
|
||||
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
|
||||
|
||||
Usage:
|
||||
@SafeProperty(int, default=-1)
|
||||
@@ -61,41 +34,6 @@ def SafeProperty(
|
||||
def some_value(self, val: int):
|
||||
# your setter logic
|
||||
...
|
||||
|
||||
# With auto-emit for toolbar sync:
|
||||
@SafeProperty(bool, auto_emit=True)
|
||||
def fft(self) -> bool:
|
||||
return self._fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, value: bool):
|
||||
self._fft = value
|
||||
# property_changed.emit("fft", value) is called automatically
|
||||
|
||||
# With custom emit modes:
|
||||
@SafeProperty(int, auto_emit=True, emit_value="stored")
|
||||
def precision_stored(self) -> int:
|
||||
return self._precision_stored
|
||||
|
||||
@precision_stored.setter
|
||||
def precision_stored(self, value: int):
|
||||
self._precision_stored = max(0, int(value))
|
||||
|
||||
@SafeProperty(int, auto_emit=True, emit_value="input")
|
||||
def precision_input(self) -> int:
|
||||
return self._precision_input
|
||||
|
||||
@precision_input.setter
|
||||
def precision_input(self, value: int):
|
||||
self._precision_input = max(0, int(value))
|
||||
|
||||
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
|
||||
def precision_callable(self) -> int:
|
||||
return self._precision_callable
|
||||
|
||||
@precision_callable.setter
|
||||
def precision_callable(self, value: int):
|
||||
self._precision_callable = max(0, int(value))
|
||||
"""
|
||||
|
||||
def decorator(py_getter):
|
||||
@@ -115,8 +53,6 @@ def SafeProperty(
|
||||
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
|
||||
return default
|
||||
|
||||
safe_getter.__is_safe_getter__ = True # type: ignore[attr-defined]
|
||||
|
||||
class PropertyWrapper:
|
||||
"""
|
||||
Intermediate wrapper used so that the user can optionally chain .setter(...).
|
||||
@@ -132,42 +68,8 @@ def SafeProperty(
|
||||
@functools.wraps(setter_func)
|
||||
def safe_setter(self_, value):
|
||||
try:
|
||||
before_value = None
|
||||
if auto_emit and emit_on_change:
|
||||
try:
|
||||
before_value = self.getter_func(self_)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"SafeProperty could not get 'before' value for change detection: {e}"
|
||||
)
|
||||
before_value = None
|
||||
|
||||
result = setter_func(self_, value)
|
||||
|
||||
# Auto-emit property_changed if auto_emit=True and signal exists
|
||||
if auto_emit and hasattr(self_, "property_changed"):
|
||||
prop_name = py_getter.__name__
|
||||
try:
|
||||
if callable(emit_value):
|
||||
emit_payload = emit_value(self_, value)
|
||||
elif emit_value == "input":
|
||||
emit_payload = value
|
||||
else:
|
||||
emit_payload = self.getter_func(self_)
|
||||
|
||||
if emit_on_change and before_value == emit_payload:
|
||||
return result
|
||||
|
||||
self_.property_changed.emit(prop_name, emit_payload)
|
||||
except Exception as notify_error:
|
||||
# Don't fail the setter if notification fails
|
||||
logger.warning(
|
||||
f"SafeProperty auto_emit failed for '{prop_name}': {notify_error}"
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"SafeProperty setter caught exception: {e}")
|
||||
return setter_func(self_, value)
|
||||
except Exception:
|
||||
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
@@ -433,100 +335,6 @@ def ErrorPopupUtility():
|
||||
return _popup_utility_instance
|
||||
|
||||
|
||||
class SafePropertyExampleWidget(QWidget): # pragma: no cover
|
||||
"""
|
||||
Example widget showcasing SafeProperty auto_emit modes.
|
||||
"""
|
||||
|
||||
property_changed = Signal(str, object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("SafeProperty auto_emit example")
|
||||
self._precision_stored = 0
|
||||
self._precision_input = 0
|
||||
self._precision_callable = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.status = QLabel("last emit: <none>", self)
|
||||
|
||||
self.spinbox_stored = QSpinBox(self)
|
||||
self.spinbox_stored.setRange(-5, 10)
|
||||
self.spinbox_stored.setValue(0)
|
||||
self.label_stored = QLabel("stored emit: <none>", self)
|
||||
|
||||
self.spinbox_input = QSpinBox(self)
|
||||
self.spinbox_input.setRange(-5, 10)
|
||||
self.spinbox_input.setValue(0)
|
||||
self.label_input = QLabel("input emit: <none>", self)
|
||||
|
||||
self.spinbox_callable = QSpinBox(self)
|
||||
self.spinbox_callable.setRange(-5, 10)
|
||||
self.spinbox_callable.setValue(0)
|
||||
self.label_callable = QLabel("callable emit: <none>", self)
|
||||
|
||||
layout.addWidget(QLabel("stored emit (normalized value):", self))
|
||||
layout.addWidget(self.spinbox_stored)
|
||||
layout.addWidget(self.label_stored)
|
||||
|
||||
layout.addWidget(QLabel("input emit (raw setter input):", self))
|
||||
layout.addWidget(self.spinbox_input)
|
||||
layout.addWidget(self.label_input)
|
||||
|
||||
layout.addWidget(QLabel("callable emit (custom mapping):", self))
|
||||
layout.addWidget(self.spinbox_callable)
|
||||
layout.addWidget(self.label_callable)
|
||||
|
||||
layout.addWidget(self.status)
|
||||
|
||||
self.spinbox_stored.valueChanged.connect(self._on_spinbox_stored)
|
||||
self.spinbox_input.valueChanged.connect(self._on_spinbox_input)
|
||||
self.spinbox_callable.valueChanged.connect(self._on_spinbox_callable)
|
||||
self.property_changed.connect(self._on_property_changed)
|
||||
|
||||
@SafeProperty(int, auto_emit=True, emit_value="stored", doc="Clamped precision value.")
|
||||
def precision_stored(self) -> int:
|
||||
return self._precision_stored
|
||||
|
||||
@precision_stored.setter
|
||||
def precision_stored(self, value: int):
|
||||
self._precision_stored = max(0, int(value))
|
||||
|
||||
@SafeProperty(int, auto_emit=True, emit_value="input", doc="Emit raw input value.")
|
||||
def precision_input(self) -> int:
|
||||
return self._precision_input
|
||||
|
||||
@precision_input.setter
|
||||
def precision_input(self, value: int):
|
||||
self._precision_input = max(0, int(value))
|
||||
|
||||
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
|
||||
def precision_callable(self) -> int:
|
||||
return self._precision_callable
|
||||
|
||||
@precision_callable.setter
|
||||
def precision_callable(self, value: int):
|
||||
self._precision_callable = max(0, int(value))
|
||||
|
||||
def _on_spinbox_stored(self, value: int):
|
||||
self.precision_stored = value
|
||||
|
||||
def _on_spinbox_input(self, value: int):
|
||||
self.precision_input = value
|
||||
|
||||
def _on_spinbox_callable(self, value: int):
|
||||
self.precision_callable = value
|
||||
|
||||
def _on_property_changed(self, prop_name: str, value):
|
||||
self.status.setText(f"last emit: {prop_name}={value}")
|
||||
if prop_name == "precision_stored":
|
||||
self.label_stored.setText(f"stored emit: {value}")
|
||||
elif prop_name == "precision_input":
|
||||
self.label_input.setText(f"input emit: {value}")
|
||||
elif prop_name == "precision_callable":
|
||||
self.label_callable.setText(f"callable emit: {value}")
|
||||
|
||||
|
||||
class ExampleWidget(QWidget): # pragma: no cover
|
||||
"""
|
||||
Example widget to demonstrate error handling with the ErrorPopupUtility.
|
||||
@@ -581,10 +389,6 @@ class ExampleWidget(QWidget): # pragma: no cover
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
tabs = QTabWidget()
|
||||
tabs.setWindowTitle("Error Popups & SafeProperty Examples")
|
||||
tabs.addTab(ExampleWidget(), "Error Popups")
|
||||
tabs.addTab(SafePropertyExampleWidget(), "SafeProperty auto_emit")
|
||||
tabs.resize(420, 520)
|
||||
tabs.show()
|
||||
widget = ExampleWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -7,7 +7,6 @@ from abc import ABC, abstractmethod
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
from typeguard import TypeCheckError
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
|
||||
@@ -56,49 +55,6 @@ class WidgetFilterHandler(ABC):
|
||||
"""
|
||||
# This method should be implemented in subclasses or extended as needed
|
||||
|
||||
def update_with_bec_signal_class(
|
||||
self,
|
||||
signal_class_filter: str | list[str],
|
||||
client,
|
||||
ndim_filter: int | list[int] | None = None,
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
|
||||
Args:
|
||||
signal_class_filter (str|list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
if not client or not hasattr(client, "device_manager"):
|
||||
return []
|
||||
|
||||
try:
|
||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||
except TypeCheckError as e:
|
||||
logger.warning(f"Error retrieving signals: {e}")
|
||||
return []
|
||||
|
||||
if ndim_filter is None:
|
||||
return signals
|
||||
|
||||
if isinstance(ndim_filter, int):
|
||||
ndim_filter = [ndim_filter]
|
||||
|
||||
filtered_signals = []
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
ndim = None
|
||||
if isinstance(signal_config, dict):
|
||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||
|
||||
if ndim in ndim_filter:
|
||||
filtered_signals.append((device_name, signal_name, signal_config))
|
||||
|
||||
return filtered_signals
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
@@ -299,32 +255,6 @@ class FilterIO:
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_with_signal_class(
|
||||
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""
|
||||
Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
signal_class_filter (list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_bec_signal_class(
|
||||
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
|
||||
@@ -106,9 +106,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
# Use title from FieldInfo if available, otherwise use the property name
|
||||
label_text = item.info.title if item.info.title else item.name
|
||||
label = QLabel(parent=self._form_grid, text=label_text)
|
||||
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)
|
||||
|
||||
@@ -231,8 +231,6 @@ 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)
|
||||
if spec.info.description:
|
||||
self._main_widget.setPlaceholderText(spec.info.description)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import weakref
|
||||
from typing import Callable, Dict, List, Literal, TypedDict
|
||||
from typing import Callable, Dict, List, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import louie
|
||||
@@ -12,18 +12,15 @@ 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, QKeySequence, QPainter, QPen, QShortcut
|
||||
from qtpy.QtGui import QAction, QColor, QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QTableWidgetItem,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -43,9 +40,9 @@ class TourStep(TypedDict):
|
||||
widget_ref: (
|
||||
louie.saferef.BoundMethodWeakref
|
||||
| weakref.ReferenceType[
|
||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
]
|
||||
| Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
| Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
| None
|
||||
)
|
||||
text: str
|
||||
@@ -67,13 +64,15 @@ class TutorialOverlay(QWidget):
|
||||
box = QFrame(self)
|
||||
app = QApplication.instance()
|
||||
bg_color = app.palette().window().color()
|
||||
box.setStyleSheet(f"""
|
||||
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)
|
||||
@@ -104,12 +103,10 @@ class TutorialOverlay(QWidget):
|
||||
# Back button with material icon
|
||||
self.back_btn = QPushButton("Back")
|
||||
self.back_btn.setIcon(material_icon("arrow_back"))
|
||||
self.back_btn.setToolTip("Press Backspace to go back")
|
||||
|
||||
# Next button with material icon
|
||||
self.next_btn = QPushButton("Next")
|
||||
self.next_btn.setIcon(material_icon("arrow_forward"))
|
||||
self.next_btn.setToolTip("Press Enter to continue")
|
||||
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.back_btn)
|
||||
@@ -118,15 +115,6 @@ class TutorialOverlay(QWidget):
|
||||
layout.addLayout(top_layout)
|
||||
layout.addWidget(self.label)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Escape closes the tour
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.close_btn.click)
|
||||
# Enter and Return activates the next button
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.next_btn.click)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.next_btn.click)
|
||||
# Map Backspace to the back button
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Backspace), self, activated=self.back_btn.click)
|
||||
|
||||
return box
|
||||
|
||||
def paintEvent(self, event): # pylint: disable=unused-argument
|
||||
@@ -235,9 +223,6 @@ class TutorialOverlay(QWidget):
|
||||
self.message_box.show()
|
||||
self.update()
|
||||
|
||||
# Update the focus policy of the buttons
|
||||
self.back_btn.setEnabled(current_step > 1)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
self.setGeometry(obj.rect())
|
||||
@@ -277,9 +262,7 @@ class GuidedTour(QObject):
|
||||
def register_widget(
|
||||
self,
|
||||
*,
|
||||
widget: (
|
||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
),
|
||||
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
|
||||
text: str = "",
|
||||
title: str = "",
|
||||
) -> str:
|
||||
@@ -287,7 +270,7 @@ class GuidedTour(QObject):
|
||||
Register a widget with help text for tours.
|
||||
|
||||
Args:
|
||||
widget (QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]): The target widget or a callable that returns the widget and its help text.
|
||||
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).
|
||||
|
||||
@@ -310,9 +293,6 @@ class GuidedTour(QObject):
|
||||
|
||||
widget_ref = _resolve_toolbar_button
|
||||
default_title = getattr(widget, "tooltip", "Toolbar Menu")
|
||||
elif isinstance(widget, QRect):
|
||||
widget_ref = widget
|
||||
default_title = "Area"
|
||||
else:
|
||||
widget_ref = saferef.safe_ref(widget)
|
||||
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
|
||||
@@ -347,14 +327,11 @@ class GuidedTour(QObject):
|
||||
if mb and mb not in menubars:
|
||||
menubars.append(mb)
|
||||
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
|
||||
menubars += [mb for mb in mw.findChildren(QMenu) 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:
|
||||
@@ -475,9 +452,9 @@ class GuidedTour(QObject):
|
||||
|
||||
if self._current_index > 0:
|
||||
self._current_index -= 1
|
||||
self._show_current_step(direction="prev")
|
||||
self._show_current_step()
|
||||
|
||||
def _show_current_step(self, direction: Literal["next"] | Literal["prev"] = "next"):
|
||||
def _show_current_step(self):
|
||||
"""Display the current step."""
|
||||
if not self._active or not self.overlay:
|
||||
return
|
||||
@@ -487,9 +464,7 @@ class GuidedTour(QObject):
|
||||
|
||||
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.", direction=direction
|
||||
)
|
||||
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
|
||||
return
|
||||
|
||||
main_window = self.main_window
|
||||
@@ -498,9 +473,7 @@ class GuidedTour(QObject):
|
||||
self.stop_tour()
|
||||
return
|
||||
|
||||
highlight_rect = self._get_highlight_rect(
|
||||
main_window, target, step_title, direction=direction
|
||||
)
|
||||
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
|
||||
if highlight_rect is None:
|
||||
return
|
||||
|
||||
@@ -510,6 +483,9 @@ class GuidedTour(QObject):
|
||||
|
||||
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:
|
||||
@@ -523,7 +499,7 @@ class GuidedTour(QObject):
|
||||
|
||||
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
|
||||
|
||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | QRect | None, str]:
|
||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
|
||||
"""
|
||||
Resolve the target widget/action for the given step.
|
||||
|
||||
@@ -531,7 +507,7 @@ class GuidedTour(QObject):
|
||||
step(TourStep): The tour step to resolve.
|
||||
|
||||
Returns:
|
||||
tuple[QWidget | QAction | QRect | None, str]: The resolved target, optional QRect, and the step text.
|
||||
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
|
||||
"""
|
||||
widget_ref = step.get("widget_ref")
|
||||
step_text = step.get("text", "")
|
||||
@@ -544,7 +520,7 @@ class GuidedTour(QObject):
|
||||
if target is None:
|
||||
return None, step_text
|
||||
|
||||
if callable(target) and not isinstance(target, (QWidget, QAction, QRect)):
|
||||
if callable(target) and not isinstance(target, (QWidget, QAction)):
|
||||
result = target()
|
||||
if isinstance(result, tuple):
|
||||
target, alt_text = result
|
||||
@@ -556,11 +532,7 @@ class GuidedTour(QObject):
|
||||
return target, step_text
|
||||
|
||||
def _get_highlight_rect(
|
||||
self,
|
||||
main_window: QWidget,
|
||||
target: QWidget | QAction | QRect,
|
||||
step_title: str,
|
||||
direction: Literal["next"] | Literal["prev"] = "next",
|
||||
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.
|
||||
@@ -573,15 +545,12 @@ class GuidedTour(QObject):
|
||||
Returns:
|
||||
QRect | None: The rectangle to highlight, or None if not found/visible.
|
||||
"""
|
||||
if isinstance(target, QRect):
|
||||
return target
|
||||
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}.",
|
||||
direction=direction,
|
||||
)
|
||||
return None
|
||||
return rect
|
||||
@@ -590,60 +559,28 @@ class GuidedTour(QObject):
|
||||
if self._visible_check:
|
||||
if not target.isVisible():
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason=f"Widget {target!r} is not visible.", direction=direction
|
||||
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())
|
||||
|
||||
if isinstance(target, QTableWidgetItem):
|
||||
# NOTE: On header items (which are also QTableWidgetItems), this does not work,
|
||||
# Header items are just used as data containers by Qt, thus, we have to directly
|
||||
# pass the QRect through the method (+ make sure the appropriate header section
|
||||
# is visible). This can be handled in the callable method.)
|
||||
table = target.tableWidget()
|
||||
|
||||
if self._visible_check:
|
||||
if not table.isVisible():
|
||||
self._advance_past_invalid_step(
|
||||
step_title,
|
||||
reason=f"Table widget {table!r} is not visible.",
|
||||
direction=direction,
|
||||
)
|
||||
return None
|
||||
|
||||
# Table item
|
||||
if table.item(target.row(), target.column()) == target:
|
||||
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
rect = table.visualItemRect(target)
|
||||
top_left = table.viewport().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)}", direction=direction
|
||||
step_title, reason=f"Unsupported step target type: {type(target)}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _advance_past_invalid_step(
|
||||
self, step_title: str, *, reason: str, direction: Literal["next"] | Literal["prev"] = "next"
|
||||
):
|
||||
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(f"{reason} Skipping step {step_title!r}.")
|
||||
if direction == "next":
|
||||
if self._current_index < len(self._tour_steps) - 1:
|
||||
self._current_index += 1
|
||||
self._show_current_step()
|
||||
else:
|
||||
self.stop_tour()
|
||||
elif direction == "prev":
|
||||
if self._current_index > 0:
|
||||
self._current_index -= 1
|
||||
self._show_current_step(direction="prev")
|
||||
else:
|
||||
self.stop_tour()
|
||||
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."""
|
||||
@@ -726,33 +663,8 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
title="Tools Menu",
|
||||
)
|
||||
|
||||
sub_menu_action = self.tools_menu_actions["notes"].action
|
||||
|
||||
def get_sub_menu_action():
|
||||
# open the tools menu
|
||||
menu_button = self.tools_menu_action._button_ref()
|
||||
if menu_button:
|
||||
menu_button.showMenu()
|
||||
|
||||
return (
|
||||
self.tools_menu_action.actions["notes"].action,
|
||||
"This action allows you to add notes.",
|
||||
)
|
||||
|
||||
sub_menu = self.guided_help.register_widget(
|
||||
widget=get_sub_menu_action,
|
||||
text="This is a sub-action within the tools menu.",
|
||||
title="Add Note Action",
|
||||
)
|
||||
|
||||
# Create tour from registered widgets
|
||||
self.tour_step_ids = [
|
||||
sub_menu,
|
||||
primary_step,
|
||||
secondary_step,
|
||||
toolbar_action_step,
|
||||
tools_menu_step,
|
||||
]
|
||||
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)
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
# 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)
|
||||
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if widget:
|
||||
if widget is self:
|
||||
self._toggle_mode(False)
|
||||
|
||||
@@ -14,22 +14,3 @@ def pascal_to_snake(name: str) -> str:
|
||||
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()
|
||||
|
||||
|
||||
def sanitize_namespace(namespace: str | None) -> str | None:
|
||||
"""
|
||||
Clean user-provided namespace labels for filesystem compatibility.
|
||||
|
||||
Args:
|
||||
namespace (str | None): Arbitrary namespace identifier supplied by the caller.
|
||||
|
||||
Returns:
|
||||
str | None: Sanitized namespace containing only safe characters, or ``None``
|
||||
when the input is empty.
|
||||
"""
|
||||
if not namespace:
|
||||
return None
|
||||
ns = namespace.strip()
|
||||
if not ns:
|
||||
return None
|
||||
return re.sub(r"[^0-9A-Za-z._-]+", "_", ns)
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -166,17 +166,18 @@ class BECClassContainer:
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
||||
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
||||
collection = BECClassContainer()
|
||||
try:
|
||||
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
||||
except ModuleNotFoundError as exc:
|
||||
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
|
||||
if exc.name == f"{repo_name}.{package}":
|
||||
return collection
|
||||
raise
|
||||
def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
@@ -184,13 +185,13 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
rel_dir = os.path.dirname(os.path.relpath(path, directory))
|
||||
if rel_dir in ("", "."):
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.{package}.{module_name}")
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
@@ -202,30 +203,12 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
class_info.is_top_level = True
|
||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||
class_info.is_plugin = True
|
||||
collection.add_class(class_info)
|
||||
return collection
|
||||
|
||||
|
||||
def get_custom_classes(
|
||||
repo_name: str, packages: tuple[str, ...] | None = None
|
||||
) -> BECClassContainer:
|
||||
"""
|
||||
Get all relevant classes for RPC/CLI in the specified repository.
|
||||
|
||||
By default, discovery is limited to ``<repo>.widgets`` for backward compatibility.
|
||||
Additional package subtrees (for example ``applications``) can be included explicitly.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility.
|
||||
|
||||
Returns:
|
||||
BECClassContainer: Container with collected class information.
|
||||
"""
|
||||
selected_packages = packages or ("widgets",)
|
||||
collection = BECClassContainer()
|
||||
for package in selected_packages:
|
||||
collection += _collect_classes_from_package(repo_name, package)
|
||||
|
||||
return collection
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
from qtpy.QtGui import QPixmap
|
||||
@@ -41,7 +40,7 @@ def compare_images(image1_path: str, reference_image_path: str):
|
||||
raise ValueError("Images are different")
|
||||
|
||||
|
||||
def snap_and_compare(widget: Any, output_directory: str, suffix: str = ""):
|
||||
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
|
||||
"""
|
||||
Save a rendering of a widget and compare it to a reference image
|
||||
|
||||
|
||||
@@ -69,11 +69,13 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
border-radius: {self._radius}px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Callable, Literal, TypeVar
|
||||
from typing import TYPE_CHECKING, Callable, TypeVar
|
||||
|
||||
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.QtWidgets import QWidget
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
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.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||
from bec_widgets.widgets.containers.dock_area.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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
@@ -34,10 +32,6 @@ logger = bec_logger.logger
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class RegistryNotReadyError(Exception):
|
||||
"""Raised when trying to access an object from the RPC registry that is not yet registered."""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
@@ -61,19 +55,6 @@ def rpc_exception_hook(err_func):
|
||||
popup.custom_exception_hook = old_exception_hook
|
||||
|
||||
|
||||
class SingleshotRPCRepeat:
|
||||
|
||||
def __init__(self, max_delay: int = 2000):
|
||||
self.max_delay = max_delay
|
||||
self.accumulated_delay = 0
|
||||
|
||||
def __iadd__(self, delay: int):
|
||||
self.accumulated_delay += delay
|
||||
if self.accumulated_delay > self.max_delay:
|
||||
raise RegistryNotReadyError("Max delay exceeded for RPC singleshot repeat")
|
||||
return self
|
||||
|
||||
|
||||
class RPCServer:
|
||||
|
||||
client: BECClient
|
||||
@@ -105,7 +86,6 @@ class RPCServer:
|
||||
self._heartbeat_timer.start(200)
|
||||
self._registry_update_callbacks = []
|
||||
self._broadcasted_data = {}
|
||||
self._rpc_singleshot_repeats: dict[str, SingleshotRPCRepeat] = {}
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
@@ -118,22 +98,18 @@ class RPCServer:
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
if method.startswith("system."):
|
||||
res = self.run_system_rpc(method, args, kwargs)
|
||||
else:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error while executing RPC instruction: {content}")
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
|
||||
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
@@ -181,149 +157,24 @@ class RPCServer:
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
target_obj, method_obj = self._resolve_rpc_target(obj, method)
|
||||
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(target_obj, method, args[0])
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
return res
|
||||
|
||||
def _resolve_rpc_target(self, obj, method: str) -> tuple[object, object]:
|
||||
"""
|
||||
Resolve a method/property access target for RPC execution.
|
||||
|
||||
Primary target is the object itself. If not found there and the class defines
|
||||
``RPC_CONTENT_CLASS``, unresolved method names can be delegated to the content
|
||||
widget referenced by ``RPC_CONTENT_ATTR`` (default ``content``), but only when
|
||||
the method is explicitly listed in the content class ``USER_ACCESS``.
|
||||
"""
|
||||
if hasattr(obj, method):
|
||||
return obj, getattr(obj, method)
|
||||
|
||||
content_cls = getattr(type(obj), "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is None:
|
||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
||||
|
||||
content_user_access = set()
|
||||
for entry in getattr(content_cls, "USER_ACCESS", []):
|
||||
if entry.endswith(".setter"):
|
||||
content_user_access.add(entry.split(".setter")[0])
|
||||
else:
|
||||
content_user_access.add(entry)
|
||||
|
||||
if method not in content_user_access:
|
||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
||||
|
||||
content_attr = getattr(type(obj), "RPC_CONTENT_ATTR", "content")
|
||||
target_obj = getattr(obj, content_attr, None)
|
||||
if target_obj is None:
|
||||
raise AttributeError(
|
||||
f"{type(obj).__name__} has no content target '{content_attr}' for RPC delegation"
|
||||
)
|
||||
if not isinstance(target_obj, content_cls):
|
||||
raise AttributeError(
|
||||
f"{type(obj).__name__}.{content_attr} is not instance of {content_cls.__name__}"
|
||||
)
|
||||
if not hasattr(target_obj, method):
|
||||
raise AttributeError(f"{content_cls.__name__} has no attribute '{method}'")
|
||||
return target_obj, getattr(target_obj, method)
|
||||
|
||||
def run_system_rpc(self, method: str, args: list, kwargs: dict):
|
||||
if method == "system.launch_dock_area":
|
||||
return self._launch_dock_area(*args, **kwargs)
|
||||
if method == "system.list_capabilities":
|
||||
return {"system.launch_dock_area": True}
|
||||
raise ValueError(f"Unknown system RPC method: {method}")
|
||||
|
||||
@staticmethod
|
||||
def _launch_dock_area(
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
||||
) -> QWidget | None:
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
if name in existing_dock_areas:
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
else:
|
||||
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
|
||||
|
||||
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def serialize_result_and_send(self, request_id: str, res: object):
|
||||
"""
|
||||
Serialize the result of an RPC call and send it back to the client.
|
||||
|
||||
Note: If the object is not yet registered in the RPC registry, this method
|
||||
will retry serialization after a short delay, up to a maximum delay. In order
|
||||
to avoid processEvents calls in the middle of serialization, QTimer.singleShot is used.
|
||||
This allows the target event to 'float' to the next event loop iteration until the
|
||||
object is registered.
|
||||
The 'jump' to the next event loop is indicated by raising a RegistryNotReadyError, see
|
||||
_serialize_bec_connector.
|
||||
|
||||
Args:
|
||||
request_id (str): The ID of the request.
|
||||
res (object): The result of the RPC call.
|
||||
"""
|
||||
retry_delay = 100
|
||||
try:
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
elif isinstance(res, dict):
|
||||
res = {key: self.serialize_object(val) for key, val in res.items()}
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
except RegistryNotReadyError:
|
||||
try:
|
||||
self._rpc_singleshot_repeats[request_id] += retry_delay
|
||||
QTimer.singleShot(
|
||||
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
|
||||
)
|
||||
except RegistryNotReadyError:
|
||||
logger.error(
|
||||
f"Max delay exceeded for RPC request {request_id}, sending error response"
|
||||
)
|
||||
self.send_response(
|
||||
request_id,
|
||||
False,
|
||||
{
|
||||
"error": f"Max delay exceeded for RPC request {request_id}, object not registered in time."
|
||||
},
|
||||
)
|
||||
self._rpc_singleshot_repeats.pop(request_id, None)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error(f"Error while serializing RPC result: {exc}")
|
||||
self.send_response(
|
||||
request_id,
|
||||
False,
|
||||
{"error": f"Error while serializing RPC result: {exc}\n{traceback.format_exc()}"},
|
||||
)
|
||||
else:
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
self._rpc_singleshot_repeats.pop(request_id, None)
|
||||
return res
|
||||
|
||||
def serialize_object(self, obj: T) -> None | dict | T:
|
||||
"""
|
||||
@@ -340,9 +191,6 @@ class RPCServer:
|
||||
# Respect RPC = False
|
||||
if getattr(obj, "RPC", True) is False:
|
||||
return None
|
||||
# Respect rpc_exposed = False
|
||||
if getattr(obj, "rpc_exposed", True) is False:
|
||||
return None
|
||||
return self._serialize_bec_connector(obj, wait=True)
|
||||
|
||||
def emit_heartbeat(self) -> None:
|
||||
@@ -371,8 +219,6 @@ class RPCServer:
|
||||
continue
|
||||
if not getattr(val, "RPC", True):
|
||||
continue
|
||||
if not getattr(val, "rpc_exposed", True):
|
||||
continue
|
||||
data[key] = self._serialize_bec_connector(val)
|
||||
if self._broadcasted_data == data:
|
||||
return
|
||||
@@ -410,8 +256,11 @@ class RPCServer:
|
||||
except Exception:
|
||||
container_proxy = None
|
||||
|
||||
if wait and not self.rpc_register.object_is_registered(connector):
|
||||
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
|
||||
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:
|
||||
@@ -423,9 +272,23 @@ class RPCServer:
|
||||
"widget_class": widget_class,
|
||||
"config": config_dict,
|
||||
"container_proxy": container_proxy,
|
||||
"__rpc__": getattr(connector, "rpc_exposed", True),
|
||||
"__rpc__": True,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
|
||||
parent = widget.parent()
|
||||
while parent is not None:
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
return None
|
||||
|
||||
# Suppose clients register callbacks to receive updates
|
||||
def add_registry_update_callback(self, cb: Callable) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import QRect
|
||||
|
||||
|
||||
def available_screen_geometry(*, widget: QWidget | None = None) -> QRect | None:
|
||||
"""
|
||||
Get the available geometry of the screen associated with the given widget or application.
|
||||
|
||||
Args:
|
||||
widget(QWidget | None): The widget to get the screen from.
|
||||
Returns:
|
||||
QRect | None: The available geometry of the screen, or None if no screen is found.
|
||||
"""
|
||||
screen = widget.screen() if widget is not None else None
|
||||
if screen is None:
|
||||
app = QApplication.instance()
|
||||
screen = app.primaryScreen() if app is not None else None
|
||||
if screen is None:
|
||||
return None
|
||||
return screen.availableGeometry()
|
||||
|
||||
|
||||
def centered_geometry(available: "QRect", width: int, height: int) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Calculate centered geometry within the available rectangle.
|
||||
|
||||
Args:
|
||||
available(QRect): The available rectangle to center within.
|
||||
width(int): The desired width.
|
||||
height(int): The desired height.
|
||||
|
||||
Returns:
|
||||
tuple[int, int, int, int]: The (x, y, width, height) of the centered geometry.
|
||||
"""
|
||||
x = available.x() + (available.width() - width) // 2
|
||||
y = available.y() + (available.height() - height) // 2
|
||||
return x, y, width, height
|
||||
|
||||
|
||||
def centered_geometry_for_app(width: int, height: int) -> tuple[int, int, int, int] | None:
|
||||
available = available_screen_geometry()
|
||||
if available is None:
|
||||
return None
|
||||
return centered_geometry(available, width, height)
|
||||
|
||||
|
||||
def scaled_centered_geometry_for_window(
|
||||
window: QWidget, *, width_ratio: float = 0.8, height_ratio: float = 0.8
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
available = available_screen_geometry(widget=window)
|
||||
if available is None:
|
||||
return None
|
||||
width = int(available.width() * width_ratio)
|
||||
height = int(available.height() * height_ratio)
|
||||
return centered_geometry(available, width, height)
|
||||
|
||||
|
||||
def apply_window_geometry(
|
||||
window: QWidget,
|
||||
geometry: tuple[int, int, int, int] | None,
|
||||
*,
|
||||
width_ratio: float = 0.8,
|
||||
height_ratio: float = 0.8,
|
||||
) -> None:
|
||||
if geometry is not None:
|
||||
window.setGeometry(*geometry)
|
||||
return
|
||||
default_geometry = scaled_centered_geometry_for_window(
|
||||
window, width_ratio=width_ratio, height_ratio=height_ratio
|
||||
)
|
||||
if default_geometry is not None:
|
||||
window.setGeometry(*default_geometry)
|
||||
else:
|
||||
window.resize(window.minimumSizeHint())
|
||||
|
||||
|
||||
def main_app_size_for_screen(available: "QRect") -> tuple[int, int]:
|
||||
height = int(available.height() * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
if width > available.width() * 0.9:
|
||||
width = int(available.width() * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
return width, height
|
||||
|
||||
|
||||
def apply_centered_size(
|
||||
window: QWidget, width: int, height: int, *, available: "QRect" | None = None
|
||||
) -> None:
|
||||
if available is None:
|
||||
available = available_screen_geometry(widget=window)
|
||||
if available is None:
|
||||
window.resize(width, height)
|
||||
return
|
||||
window.setGeometry(*centered_geometry(available, width, height))
|
||||
@@ -26,7 +26,6 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
@@ -499,82 +498,6 @@ class WidgetAction(ToolBarAction):
|
||||
return max_width + 60
|
||||
|
||||
|
||||
class SplitterAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding a draggable splitter/spacer to the toolbar.
|
||||
|
||||
This creates a resizable spacer that allows users to control how much space
|
||||
is allocated to toolbar sections before and after it. When dragged, it expands/contracts,
|
||||
pushing other toolbar elements left or right.
|
||||
|
||||
Args:
|
||||
orientation (Literal["horizontal", "vertical", "auto"]): The orientation of the splitter.
|
||||
parent (QWidget): The parent widget.
|
||||
initial_width (int): Fixed size of the spacer in pixels along the toolbar's orientation (default: 20).
|
||||
min_width (int | None): Minimum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no minimum constraint is applied.
|
||||
max_width (int | None): Maximum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no maximum constraint is applied.
|
||||
target_widget (QWidget | None): Widget whose size (width or height, depending on orientation) is controlled by the spacer within the given min/max bounds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
orientation: Literal["horizontal", "vertical", "auto"] = "auto",
|
||||
parent=None,
|
||||
initial_width=20,
|
||||
min_width: int | None = None,
|
||||
max_width: int | None = None,
|
||||
target_widget=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip="Drag to resize toolbar sections", checkable=False)
|
||||
self.orientation = orientation
|
||||
self.initial_width = initial_width
|
||||
self.min_width = min_width
|
||||
self.max_width = max_width
|
||||
self._splitter_widget = None
|
||||
self._target_widget = target_widget
|
||||
|
||||
def _resolve_orientation(self, toolbar: QToolBar) -> Literal["horizontal", "vertical"]:
|
||||
if self.orientation in (None, "auto"):
|
||||
return (
|
||||
"horizontal" if toolbar.orientation() == Qt.Orientation.Horizontal else "vertical"
|
||||
)
|
||||
return self.orientation
|
||||
|
||||
def set_target_widget(self, widget):
|
||||
"""Set the target widget after creation."""
|
||||
self._target_widget = widget
|
||||
if self._splitter_widget:
|
||||
self._splitter_widget.set_target_widget(widget)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the splitter/spacer to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the splitter to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
effective_orientation = self._resolve_orientation(toolbar)
|
||||
self._splitter_widget = ResizableSpacer(
|
||||
parent=target,
|
||||
orientation=effective_orientation,
|
||||
initial_width=self.initial_width,
|
||||
min_target_size=self.min_width,
|
||||
max_target_size=self.max_width,
|
||||
target_widget=self._target_widget,
|
||||
)
|
||||
toolbar.addWidget(self._splitter_widget)
|
||||
self.action = self._splitter_widget # type: ignore
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up the splitter widget."""
|
||||
if self._splitter_widget is not None:
|
||||
self._splitter_widget.close()
|
||||
self._splitter_widget.deleteLater()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
class ExpandableMenuAction(ToolBarAction):
|
||||
"""
|
||||
Action for an expandable menu in the toolbar.
|
||||
@@ -599,14 +522,16 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
button.setStyleSheet("""
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_container in self.actions.values():
|
||||
action: QAction = action_container.action
|
||||
|
||||
@@ -7,17 +7,10 @@ from weakref import ReferenceType
|
||||
import louie
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction
|
||||
|
||||
DEFAULT_SIZE = 400
|
||||
MAX_SIZE = 10_000_000
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
@@ -202,84 +195,6 @@ class ToolbarBundle:
|
||||
"""
|
||||
self.add_action("separator")
|
||||
|
||||
def add_splitter(
|
||||
self,
|
||||
name: str = "splitter",
|
||||
target_widget: QWidget | None = None,
|
||||
initial_width: int = 10,
|
||||
min_width: int | None = None,
|
||||
max_width: int | None = None,
|
||||
size_policy_expanding: bool = True,
|
||||
):
|
||||
"""
|
||||
Adds a resizable splitter action to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the splitter action.
|
||||
target_widget (QWidget, optional): The widget whose size (width for horizontal,
|
||||
height for vertical orientation) will be controlled by the splitter. If None,
|
||||
the splitter will not control any widget.
|
||||
initial_width (int): The initial size of the splitter (width for horizontal,
|
||||
height for vertical orientation).
|
||||
min_width (int, optional): The minimum size the target widget can be resized to
|
||||
(width for horizontal, height for vertical orientation). If None, the target
|
||||
widget's minimum size hint in that orientation will be used.
|
||||
max_width (int, optional): The maximum size the target widget can be resized to
|
||||
(width for horizontal, height for vertical orientation). If None, the target
|
||||
widget's maximum size hint in that orientation will be used.
|
||||
size_policy_expanding (bool): If True, the size policy of the target_widget will be
|
||||
set to Expanding in the appropriate orientation if it is not already set.
|
||||
"""
|
||||
|
||||
# Resolve effective bounds
|
||||
eff_min = min_width if min_width is not None else None
|
||||
eff_max = max_width if max_width is not None else None
|
||||
|
||||
is_horizontal = self.components.toolbar.orientation() == Qt.Orientation.Horizontal
|
||||
|
||||
if target_widget is not None:
|
||||
# Use widget hints if bounds not provided
|
||||
if eff_min is None:
|
||||
eff_min = (
|
||||
target_widget.minimumWidth() if is_horizontal else target_widget.minimumHeight()
|
||||
) or 6
|
||||
if eff_max is None:
|
||||
mw = (
|
||||
target_widget.maximumWidth() if is_horizontal else target_widget.maximumHeight()
|
||||
)
|
||||
eff_max = mw if mw and mw < MAX_SIZE else DEFAULT_SIZE # avoid "no limit"
|
||||
|
||||
# Adjust size policy if needed
|
||||
if size_policy_expanding:
|
||||
size_policy = target_widget.sizePolicy()
|
||||
|
||||
if is_horizontal:
|
||||
if size_policy.horizontalPolicy() not in (
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.MinimumExpanding,
|
||||
):
|
||||
size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
|
||||
target_widget.setSizePolicy(size_policy)
|
||||
else:
|
||||
if size_policy.verticalPolicy() not in (
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.MinimumExpanding,
|
||||
):
|
||||
size_policy.setVerticalPolicy(QSizePolicy.Policy.Expanding)
|
||||
target_widget.setSizePolicy(size_policy)
|
||||
|
||||
splitter_action = SplitterAction(
|
||||
orientation="auto",
|
||||
parent=self.components.toolbar,
|
||||
initial_width=initial_width,
|
||||
min_width=eff_min,
|
||||
max_width=eff_max,
|
||||
target_widget=target_widget,
|
||||
)
|
||||
|
||||
self.components.add_safe(name, splitter_action)
|
||||
self.add_action(name)
|
||||
|
||||
def add_connection(self, name: str, connection):
|
||||
"""
|
||||
Adds a connection to the bundle.
|
||||
|
||||
@@ -1,136 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BundleConnection(QObject):
|
||||
"""
|
||||
Base class for toolbar bundle connections.
|
||||
|
||||
Provides infrastructure for bidirectional property-toolbar synchronization:
|
||||
- Toolbar actions → Widget properties (via action.triggered connections)
|
||||
- Widget properties → Toolbar actions (via property_changed signal)
|
||||
"""
|
||||
|
||||
bundle_name: str
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._property_sync_methods: dict[str, Callable] = {}
|
||||
self._property_sync_connected = False
|
||||
|
||||
def register_property_sync(self, prop_name: str, sync_method: Callable):
|
||||
"""
|
||||
Register a method to synchronize toolbar state when a property changes.
|
||||
|
||||
This enables automatic toolbar updates when properties are set programmatically,
|
||||
restored from QSettings, or changed via RPC.
|
||||
|
||||
Args:
|
||||
prop_name: The property name to watch (e.g., "fft", "log", "x_grid")
|
||||
sync_method: Method to call when property changes. Should accept the new value
|
||||
and update toolbar state (typically with signals blocked to prevent loops)
|
||||
|
||||
Example:
|
||||
def _sync_fft_toolbar(self, value: bool):
|
||||
self.fft_action.blockSignals(True)
|
||||
self.fft_action.setChecked(value)
|
||||
self.fft_action.blockSignals(False)
|
||||
|
||||
self.register_property_sync("fft", self._sync_fft_toolbar)
|
||||
"""
|
||||
self._property_sync_methods[prop_name] = sync_method
|
||||
|
||||
def _resolve_action(self, action_like):
|
||||
if hasattr(action_like, "action"):
|
||||
return action_like.action
|
||||
return action_like
|
||||
|
||||
def register_checked_action_sync(self, prop_name: str, action_like):
|
||||
"""
|
||||
Register a property sync for a checkable QAction (or wrapper with .action).
|
||||
|
||||
This reduces boilerplate for simple boolean → checked state updates.
|
||||
"""
|
||||
qt_action = self._resolve_action(action_like)
|
||||
|
||||
def _sync_checked(value):
|
||||
qt_action.blockSignals(True)
|
||||
try:
|
||||
qt_action.setChecked(bool(value))
|
||||
finally:
|
||||
qt_action.blockSignals(False)
|
||||
|
||||
self.register_property_sync(prop_name, _sync_checked)
|
||||
|
||||
def connect_property_sync(self, target_widget):
|
||||
"""
|
||||
Connect to target widget's property_changed signal for automatic toolbar sync.
|
||||
|
||||
Call this in your connect() method after registering all property syncs.
|
||||
|
||||
Args:
|
||||
target_widget: The widget to monitor for property changes
|
||||
"""
|
||||
if self._property_sync_connected:
|
||||
return
|
||||
|
||||
if hasattr(target_widget, "property_changed"):
|
||||
target_widget.property_changed.connect(self._on_property_changed)
|
||||
self._property_sync_connected = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"{target_widget.__class__.__name__} does not have property_changed signal. "
|
||||
"Property-toolbar sync will not work."
|
||||
)
|
||||
|
||||
def disconnect_property_sync(self, target_widget):
|
||||
"""
|
||||
Disconnect from target widget's property_changed signal.
|
||||
|
||||
Call this in your disconnect() method.
|
||||
|
||||
Args:
|
||||
target_widget: The widget to stop monitoring
|
||||
"""
|
||||
if not self._property_sync_connected:
|
||||
return
|
||||
|
||||
if hasattr(target_widget, "property_changed"):
|
||||
try:
|
||||
target_widget.property_changed.disconnect(self._on_property_changed)
|
||||
except (RuntimeError, TypeError):
|
||||
# Signal already disconnected or connection doesn't exist
|
||||
pass
|
||||
self._property_sync_connected = False
|
||||
|
||||
def _on_property_changed(self, prop_name: str, value):
|
||||
"""
|
||||
Internal handler for property changes.
|
||||
|
||||
Calls the registered sync method for the changed property.
|
||||
"""
|
||||
if prop_name in self._property_sync_methods:
|
||||
try:
|
||||
self._property_sync_methods[prop_name](value)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error syncing toolbar for property '{prop_name}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def connect(self):
|
||||
"""
|
||||
Connects the bundle to the target widget or application.
|
||||
This method should be implemented by subclasses to define how the bundle interacts with the target.
|
||||
|
||||
Subclasses should call connect_property_sync(target_widget) if property sync is needed.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -138,6 +20,4 @@ class BundleConnection(QObject):
|
||||
"""
|
||||
Disconnects the bundle from the target widget or application.
|
||||
This method should be implemented by subclasses to define how to clean up connections.
|
||||
|
||||
Subclasses should call disconnect_property_sync(target_widget) if property sync was connected.
|
||||
"""
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""
|
||||
Draggable splitter for toolbars to allow resizing of toolbar sections.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QPoint, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QPainter
|
||||
from qtpy.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
|
||||
class ResizableSpacer(QWidget):
|
||||
"""
|
||||
A resizable spacer widget for toolbars that can be dragged to expand/contract.
|
||||
|
||||
When connected to a widget, it controls that widget's size along the spacer's
|
||||
orientation (maximum width for horizontal, maximum height for vertical),
|
||||
ensuring the widget stays flush against the spacer with no gaps.
|
||||
|
||||
Args:
|
||||
parent(QWidget | None): Parent widget.
|
||||
orientation(Literal["horizontal", "vertical"]): Orientation of the spacer.
|
||||
initial_width(int): Initial size of the spacer in pixels along the orientation
|
||||
(width for horizontal, height for vertical).
|
||||
min_target_size(int): Minimum size of the target widget when resized along the
|
||||
orientation (width for horizontal, height for vertical).
|
||||
max_target_size(int): Maximum size of the target widget when resized along the
|
||||
orientation (width for horizontal, height for vertical).
|
||||
target_widget: QWidget | None. The widget whose size along the orientation
|
||||
is controlled by this spacer.
|
||||
"""
|
||||
|
||||
size_changed = Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
initial_width: int = 10,
|
||||
min_target_size: int = 6,
|
||||
max_target_size: int = 500,
|
||||
target_widget: QWidget = None,
|
||||
):
|
||||
from bec_widgets.utils.toolbars.bundles import DEFAULT_SIZE, MAX_SIZE
|
||||
|
||||
super().__init__(parent)
|
||||
self._target_start_size = None
|
||||
self.orientation = orientation
|
||||
self._current_width = initial_width
|
||||
self._min_width = min_target_size
|
||||
self._max_width = max_target_size
|
||||
self._dragging = False
|
||||
self._drag_start_pos = QPoint()
|
||||
self._target_widget = target_widget
|
||||
|
||||
# Determine bounds from kwargs or target hints
|
||||
is_horizontal = orientation == "horizontal"
|
||||
target_min = target_widget.minimumWidth() if (target_widget and is_horizontal) else 0
|
||||
if target_widget and not is_horizontal:
|
||||
target_min = target_widget.minimumHeight()
|
||||
target_hint = target_widget.sizeHint().width() if (target_widget and is_horizontal) else 0
|
||||
if target_widget and not is_horizontal:
|
||||
target_hint = target_widget.sizeHint().height()
|
||||
target_max_hint = (
|
||||
target_widget.maximumWidth() if (target_widget and is_horizontal) else None
|
||||
)
|
||||
if target_widget and not is_horizontal:
|
||||
target_max_hint = target_widget.maximumHeight()
|
||||
self._min_target = min_target_size if min_target_size is not None else (target_min or 6)
|
||||
self._max_target = (
|
||||
max_target_size
|
||||
if max_target_size is not None
|
||||
else (
|
||||
target_max_hint if target_max_hint and target_max_hint < MAX_SIZE else DEFAULT_SIZE
|
||||
)
|
||||
)
|
||||
|
||||
# Determine a reasonable base width and clamp to bounds
|
||||
if target_widget:
|
||||
current_size = target_widget.width() if is_horizontal else target_widget.height()
|
||||
if current_size > 0:
|
||||
self._base_width = current_size
|
||||
elif target_min > 0:
|
||||
self._base_width = target_min
|
||||
elif target_hint > 0:
|
||||
self._base_width = target_hint
|
||||
else:
|
||||
self._base_width = 240
|
||||
else:
|
||||
self._base_width = 240
|
||||
self._base_width = max(self._min_target, min(self._max_target, self._base_width))
|
||||
|
||||
# Set size constraints - Fixed policy to prevent automatic resizing
|
||||
# Match toolbar height for proper alignment
|
||||
self._toolbar_height = 32 # Standard toolbar height
|
||||
|
||||
if orientation == "horizontal":
|
||||
self.setFixedWidth(initial_width)
|
||||
self.setFixedHeight(self._toolbar_height)
|
||||
self.setCursor(Qt.CursorShape.SplitHCursor)
|
||||
else:
|
||||
self.setFixedHeight(initial_width)
|
||||
self.setFixedWidth(self._toolbar_height)
|
||||
self.setCursor(Qt.CursorShape.SplitVCursor)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self.setStyleSheet("""
|
||||
ResizableSpacer {
|
||||
background-color: transparent;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
}
|
||||
ResizableSpacer:hover {
|
||||
background-color: rgba(100, 100, 200, 80);
|
||||
}
|
||||
""")
|
||||
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
if self._target_widget:
|
||||
size_policy = self._target_widget.sizePolicy()
|
||||
if is_horizontal:
|
||||
vertical_policy = size_policy.verticalPolicy()
|
||||
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
|
||||
else:
|
||||
horizontal_policy = size_policy.horizontalPolicy()
|
||||
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
|
||||
|
||||
# Load Material icon based on orientation
|
||||
icon_name = "more_vert" if orientation == "horizontal" else "more_horiz"
|
||||
icon_size = 24
|
||||
self._icon = material_icon(icon_name, size=(icon_size, icon_size), convert_to_pixmap=False)
|
||||
self._icon_size = icon_size
|
||||
|
||||
def set_target_widget(self, widget):
|
||||
"""Set the widget whose size is controlled by this spacer."""
|
||||
self._target_widget = widget
|
||||
if widget:
|
||||
is_horizontal = self.orientation == "horizontal"
|
||||
target_min = widget.minimumWidth() if is_horizontal else widget.minimumHeight()
|
||||
target_hint = widget.sizeHint().width() if is_horizontal else widget.sizeHint().height()
|
||||
target_max_hint = widget.maximumWidth() if is_horizontal else widget.maximumHeight()
|
||||
self._min_target = self._min_target or (target_min or 6)
|
||||
self._max_target = (
|
||||
self._max_target
|
||||
if self._max_target is not None
|
||||
else (target_max_hint if target_max_hint and target_max_hint < 10_000_000 else 400)
|
||||
)
|
||||
current_size = widget.width() if is_horizontal else widget.height()
|
||||
if current_size is not None and current_size > 0:
|
||||
base = current_size
|
||||
elif target_min is not None and target_min > 0:
|
||||
base = target_min
|
||||
elif target_hint is not None and target_hint > 0:
|
||||
base = target_hint
|
||||
else:
|
||||
base = self._base_width
|
||||
base = max(self._min_target, min(self._max_target, base))
|
||||
if is_horizontal:
|
||||
widget.setFixedWidth(base)
|
||||
else:
|
||||
widget.setFixedHeight(base)
|
||||
|
||||
def get_target_widget(self):
|
||||
"""Get the widget whose size is controlled by this spacer."""
|
||||
return self._target_widget
|
||||
|
||||
def sizeHint(self):
|
||||
if self.orientation == "horizontal":
|
||||
return QSize(self._current_width, self._toolbar_height)
|
||||
else:
|
||||
return QSize(self._toolbar_height, self._current_width)
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Draw the Material icon centered in the widget using stored icon size
|
||||
x = (self.width() - self._icon_size) // 2
|
||||
y = (self.height() - self._icon_size) // 2
|
||||
|
||||
self._icon.paint(painter, x, y, self._icon_size, self._icon_size)
|
||||
painter.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._dragging = True
|
||||
self._drag_start_pos = event.globalPos()
|
||||
# Store target's current width if it exists
|
||||
if self._target_widget:
|
||||
if self.orientation == "horizontal":
|
||||
self._target_start_size = self._target_widget.width()
|
||||
else:
|
||||
self._target_start_size = self._target_widget.height()
|
||||
|
||||
size_policy = self._target_widget.sizePolicy()
|
||||
if self.orientation == "horizontal":
|
||||
vertical_policy = size_policy.verticalPolicy()
|
||||
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
|
||||
self._target_widget.setFixedWidth(self._target_start_size)
|
||||
else:
|
||||
horizontal_policy = size_policy.horizontalPolicy()
|
||||
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
|
||||
self._target_widget.setFixedHeight(self._target_start_size)
|
||||
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._dragging:
|
||||
current_pos = event.globalPos()
|
||||
delta = current_pos - self._drag_start_pos
|
||||
|
||||
if self.orientation == "horizontal":
|
||||
delta_pixels = delta.x()
|
||||
else:
|
||||
delta_pixels = delta.y()
|
||||
|
||||
if self._target_widget:
|
||||
new_target_size = self._target_start_size + delta_pixels
|
||||
new_target_size = max(self._min_target, min(self._max_target, new_target_size))
|
||||
|
||||
if self.orientation == "horizontal":
|
||||
if new_target_size != self._target_widget.width():
|
||||
self._target_widget.setFixedWidth(new_target_size)
|
||||
self.size_changed.emit(new_target_size)
|
||||
else:
|
||||
if new_target_size != self._target_widget.height():
|
||||
self._target_widget.setFixedHeight(new_target_size)
|
||||
self.size_changed.emit(new_target_size)
|
||||
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._dragging = False
|
||||
event.accept()
|
||||
@@ -8,19 +8,10 @@ from typing import DefaultDict, Literal
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
@@ -291,7 +282,8 @@ class ModularToolBar(QToolBar):
|
||||
menu = QMenu(self)
|
||||
theme = get_theme_name()
|
||||
if theme == "dark":
|
||||
menu.setStyleSheet("""
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
@@ -299,10 +291,12 @@ class ModularToolBar(QToolBar):
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Light theme styling
|
||||
menu.setStyleSheet("""
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
@@ -310,7 +304,8 @@ class ModularToolBar(QToolBar):
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
for ii, bundle in enumerate(self.shown_bundles):
|
||||
self.handle_bundle_context_menu(menu, bundle)
|
||||
if ii < len(self.shown_bundles) - 1:
|
||||
@@ -411,18 +406,9 @@ class ModularToolBar(QToolBar):
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them.
|
||||
Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them.
|
||||
Hide separators that are adjacent to another separator or have no non-separator actions between them.
|
||||
"""
|
||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||
|
||||
toolbar_actions = self.actions()
|
||||
|
||||
# Helper function to check if a widget is a splitter
|
||||
def is_splitter_widget(action):
|
||||
widget = self.widgetForAction(action)
|
||||
return widget is not None and isinstance(widget, ResizableSpacer)
|
||||
|
||||
# First pass: set visibility based on surrounding non-separator actions.
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if not action.isSeparator():
|
||||
@@ -437,32 +423,23 @@ class ModularToolBar(QToolBar):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
|
||||
# Hide separator if adjacent to another separator, splitter, or at edges
|
||||
if (
|
||||
prev_visible is None
|
||||
or prev_visible.isSeparator()
|
||||
or is_splitter_widget(prev_visible)
|
||||
) and (
|
||||
next_visible is None
|
||||
or next_visible.isSeparator()
|
||||
or is_splitter_widget(next_visible)
|
||||
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||
next_visible is None or next_visible.isSeparator()
|
||||
):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
action.setVisible(True)
|
||||
# Second pass: ensure no two visible separators are adjacent, and no separators next to splitters.
|
||||
# Second pass: ensure no two visible separators are adjacent.
|
||||
prev = None
|
||||
for action in toolbar_actions:
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
# Hide separator if previous visible item was a separator or splitter
|
||||
if prev and (prev.isSeparator() or is_splitter_widget(prev)):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
if action.isVisible() and action.isSeparator():
|
||||
if prev and prev.isSeparator():
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
if action.isVisible():
|
||||
prev = action
|
||||
|
||||
if not toolbar_actions:
|
||||
return
|
||||
@@ -504,31 +481,12 @@ if __name__ == "__main__": # pragma: no cover
|
||||
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
self.test_label = QLabel(text="Drag the splitter (⋮) to resize!")
|
||||
self.test_label = QLabel(text="This is a test label.")
|
||||
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
||||
self.central_widget.layout.addWidget(self.test_label)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
|
||||
# Example: Bare combobox (no container). Give it a stable starting width
|
||||
self.example_combo = QComboBox(parent=self)
|
||||
self.example_combo.addItems(["device_1", "device_2", "device_3"])
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"example_combo", WidgetAction(widget=self.example_combo)
|
||||
)
|
||||
|
||||
# Create a bundle with the combobox and a splitter
|
||||
self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components)
|
||||
self.bundle_combo_splitter.add_action("example_combo")
|
||||
# Add splitter; target the bare widget
|
||||
self.bundle_combo_splitter.add_splitter(
|
||||
name="splitter_example", target_widget=self.example_combo, min_width=100
|
||||
)
|
||||
|
||||
# Add other bundles
|
||||
self.toolbar.add_bundle(self.bundle_combo_splitter)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
@@ -544,9 +502,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
text_position="under",
|
||||
),
|
||||
)
|
||||
|
||||
# Show bundles - notice how performance and plot_export appear compactly after splitter!
|
||||
self.toolbar.show_bundles(["example_combo", "performance", "plot_export"])
|
||||
self.toolbar.show_bundles(["performance", "plot_export"])
|
||||
self.toolbar.get_bundle("performance").add_action("save")
|
||||
self.toolbar.get_bundle("performance").add_action("text")
|
||||
self.toolbar.refresh()
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shiboken6
|
||||
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt
|
||||
from qtpy.QtWidgets import QFrame, QWidget
|
||||
|
||||
|
||||
class WidgetHighlighter:
|
||||
"""
|
||||
Utility that highlights widgets by drawing a temporary frame around them.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
frame_parent: QWidget | None = None,
|
||||
window_flags: Qt.WindowType | Qt.WindowFlags = Qt.WindowType.Tool
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint,
|
||||
style_sheet: str = "border: 2px solid #FF00FF; border-radius: 6px; background: transparent;",
|
||||
) -> None:
|
||||
self._frame_parent = frame_parent
|
||||
self._window_flags = window_flags
|
||||
self._style_sheet = style_sheet
|
||||
self._frame: QFrame | None = None
|
||||
self._animation_group: QSequentialAnimationGroup | None = None
|
||||
|
||||
def highlight(self, widget: QWidget | None) -> None:
|
||||
"""
|
||||
Highlight the given widget with a pulsing frame.
|
||||
"""
|
||||
if widget is None or not shiboken6.isValid(widget):
|
||||
return
|
||||
|
||||
frame = self._ensure_frame()
|
||||
frame.hide()
|
||||
|
||||
geom = widget.frameGeometry()
|
||||
top_left = widget.mapToGlobal(widget.rect().topLeft())
|
||||
frame.setGeometry(top_left.x(), top_left.y(), geom.width(), geom.height())
|
||||
frame.setWindowOpacity(1.0)
|
||||
frame.show()
|
||||
|
||||
start_rect = QRect(
|
||||
top_left.x() - 5, top_left.y() - 5, geom.width() + 10, geom.height() + 10
|
||||
)
|
||||
|
||||
pulse = QPropertyAnimation(frame, b"geometry", frame)
|
||||
pulse.setDuration(300)
|
||||
pulse.setStartValue(start_rect)
|
||||
pulse.setEndValue(QRect(top_left.x(), top_left.y(), geom.width(), geom.height()))
|
||||
|
||||
fade = QPropertyAnimation(frame, b"windowOpacity", frame)
|
||||
fade.setDuration(2000)
|
||||
fade.setStartValue(1.0)
|
||||
fade.setEndValue(0.0)
|
||||
fade.finished.connect(frame.hide)
|
||||
|
||||
if self._animation_group is not None:
|
||||
old_group = self._animation_group
|
||||
self._animation_group = None
|
||||
old_group.stop()
|
||||
old_group.deleteLater()
|
||||
|
||||
animation = QSequentialAnimationGroup(frame)
|
||||
animation.addAnimation(pulse)
|
||||
animation.addAnimation(fade)
|
||||
animation.start()
|
||||
|
||||
self._animation_group = animation
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Delete the highlight frame and cancel pending animations.
|
||||
"""
|
||||
if self._animation_group is not None:
|
||||
self._animation_group.stop()
|
||||
self._animation_group.deleteLater()
|
||||
self._animation_group = None
|
||||
if self._frame is not None:
|
||||
self._frame.hide()
|
||||
self._frame.deleteLater()
|
||||
self._frame = None
|
||||
|
||||
@property
|
||||
def frame(self) -> QFrame | None:
|
||||
"""Return the currently allocated highlight frame (if any)."""
|
||||
return self._frame
|
||||
|
||||
def _ensure_frame(self) -> QFrame:
|
||||
if self._frame is None:
|
||||
self._frame = QFrame(self._frame_parent, self._window_flags)
|
||||
self._frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self._frame.setStyleSheet(self._style_sheet)
|
||||
return self._frame
|
||||
@@ -2,12 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Type, TypeVar, cast
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -33,14 +31,6 @@ logger = bec_logger.logger
|
||||
TAncestor = TypeVar("TAncestor", bound=QWidget)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WidgetTreeNode:
|
||||
widget: QWidget
|
||||
parent: QWidget | None
|
||||
depth: int
|
||||
prefix: str
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
|
||||
@@ -330,72 +320,6 @@ class WidgetIO:
|
||||
|
||||
|
||||
class WidgetHierarchy:
|
||||
@staticmethod
|
||||
def iter_widget_tree(widget: QWidget, *, exclude_internal_widgets: bool = True):
|
||||
"""
|
||||
Yield WidgetTreeNode entries for the widget hierarchy.
|
||||
"""
|
||||
visited: set[int] = set()
|
||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
||||
widget, None, exclude_internal_widgets, visited, [], 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iter_widget_tree_nodes(
|
||||
widget: QWidget,
|
||||
parent: QWidget | None,
|
||||
exclude_internal_widgets: bool,
|
||||
visited: set[int],
|
||||
branch_flags: list[bool],
|
||||
depth: int,
|
||||
):
|
||||
if widget is None or not shb.isValid(widget):
|
||||
return
|
||||
widget_id = id(widget)
|
||||
if widget_id in visited:
|
||||
return
|
||||
visited.add(widget_id)
|
||||
|
||||
prefix = WidgetHierarchy._build_prefix(branch_flags)
|
||||
yield WidgetTreeNode(widget=widget, parent=parent, depth=depth, prefix=prefix)
|
||||
|
||||
children = WidgetHierarchy._filtered_children(widget, exclude_internal_widgets)
|
||||
for idx, child in enumerate(children):
|
||||
is_last = idx == len(children) - 1
|
||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
||||
child,
|
||||
widget,
|
||||
exclude_internal_widgets,
|
||||
visited,
|
||||
branch_flags + [is_last],
|
||||
depth + 1,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_prefix(branch_flags: list[bool]) -> str:
|
||||
if not branch_flags:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for flag in branch_flags[:-1]:
|
||||
parts.append(" " if flag else "│ ")
|
||||
parts.append("└─ " if branch_flags[-1] else "├─ ")
|
||||
return "".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _filtered_children(widget: QWidget, exclude_internal_widgets: bool) -> list[QWidget]:
|
||||
children: list[QWidget] = []
|
||||
for child in widget.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
if not shb.isValid(child):
|
||||
continue
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
children.append(child)
|
||||
return children
|
||||
|
||||
@staticmethod
|
||||
def print_widget_hierarchy(
|
||||
widget,
|
||||
@@ -421,33 +345,52 @@ class WidgetHierarchy:
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
for node in WidgetHierarchy.iter_widget_tree(
|
||||
widget, exclude_internal_widgets=exclude_internal_widgets
|
||||
):
|
||||
current = node.widget
|
||||
is_bec = isinstance(current, BECConnector)
|
||||
if only_bec_widgets and not is_bec:
|
||||
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
|
||||
is_bec = isinstance(widget, BECConnector)
|
||||
if only_bec_widgets and not is_bec:
|
||||
return
|
||||
|
||||
# 2) Determine and print the parent's info (closest BECConnector)
|
||||
parent_info = ""
|
||||
if show_parent and is_bec:
|
||||
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if ancestor:
|
||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||
parent_info = f" parent={parent_label}"
|
||||
else:
|
||||
parent_info = " parent=None"
|
||||
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
|
||||
print(prefix + widget_info)
|
||||
|
||||
# 3) If it's a Waveform, explicitly print the curves
|
||||
if isinstance(widget, Waveform):
|
||||
for curve in widget.curves:
|
||||
curve_prefix = prefix + " └─ "
|
||||
print(
|
||||
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
|
||||
f"parent={widget.objectName()}"
|
||||
)
|
||||
|
||||
# 4) Recursively handle each child if:
|
||||
# - It's a QWidget
|
||||
# - It is a BECConnector (or we don't care about filtering)
|
||||
# - Its closest BECConnector parent is the current widget
|
||||
for child in widget.findChildren(QWidget):
|
||||
if only_bec_widgets and not isinstance(child, BECConnector):
|
||||
continue
|
||||
|
||||
parent_info = ""
|
||||
if show_parent and is_bec:
|
||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(current)
|
||||
if ancestor:
|
||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||
parent_info = f" parent={parent_label}"
|
||||
else:
|
||||
parent_info = " parent=None"
|
||||
|
||||
widget_info = f"{current.__class__.__name__} ({current.objectName()}){parent_info}"
|
||||
print(node.prefix + widget_info)
|
||||
|
||||
if isinstance(current, Waveform):
|
||||
for curve in current.curves:
|
||||
curve_prefix = node.prefix + " "
|
||||
print(
|
||||
f"{curve_prefix}└─ {curve.__class__.__name__} ({curve.objectName()}) "
|
||||
f"parent={current.objectName()}"
|
||||
)
|
||||
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
|
||||
child_prefix = prefix + " └─ "
|
||||
WidgetHierarchy.print_widget_hierarchy(
|
||||
child,
|
||||
indent=indent + 1,
|
||||
grab_values=grab_values,
|
||||
prefix=child_prefix,
|
||||
exclude_internal_widgets=exclude_internal_widgets,
|
||||
only_bec_widgets=only_bec_widgets,
|
||||
show_parent=show_parent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def print_becconnector_hierarchy_from_app():
|
||||
@@ -487,7 +430,7 @@ class WidgetHierarchy:
|
||||
# 3) Build a map of (closest BECConnector parent) -> list of children
|
||||
parent_map = defaultdict(list)
|
||||
for w in bec_widgets:
|
||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(w)
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
|
||||
parent_map[parent_bec].append(w)
|
||||
|
||||
# 4) Define a recursive printer to show each object's children
|
||||
@@ -524,15 +467,10 @@ class WidgetHierarchy:
|
||||
print_tree(root, prefix=" ")
|
||||
|
||||
@staticmethod
|
||||
def get_becwidget_ancestor(widget):
|
||||
def _get_becwidget_ancestor(widget):
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
|
||||
Args:
|
||||
widget: Starting widget to find the ancestor for.
|
||||
|
||||
Returns:
|
||||
The nearest ancestor that is a BECConnector, or None if not found.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
@@ -642,7 +580,7 @@ class WidgetHierarchy:
|
||||
if isinstance(widget, BECConnector):
|
||||
connectors.append(widget)
|
||||
for child in widget.findChildren(BECConnector):
|
||||
if WidgetHierarchy.get_becwidget_ancestor(child) is widget:
|
||||
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||
connectors.append(child)
|
||||
return connectors
|
||||
|
||||
@@ -673,7 +611,7 @@ class WidgetHierarchy:
|
||||
is_bec_target = issubclass(ancestor_class, BECConnector)
|
||||
|
||||
if is_bec_target:
|
||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(widget)
|
||||
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
return cast(TAncestor, ancestor)
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing BECConnector: {e}")
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal, Mapping, Sequence
|
||||
from typing import Callable, Literal, Mapping, Sequence
|
||||
|
||||
import slugify
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -19,11 +18,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.views.view import ViewTourSteps
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
@@ -32,15 +29,14 @@ from bec_widgets.utils.toolbars.actions import (
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
default_profile_candidates,
|
||||
delete_profile_files,
|
||||
get_last_profile,
|
||||
is_profile_read_only,
|
||||
is_quick_select,
|
||||
list_profiles,
|
||||
list_quick_profiles,
|
||||
load_default_profile_screenshot,
|
||||
load_user_profile_screenshot,
|
||||
@@ -51,17 +47,20 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
profile_origin_display,
|
||||
read_manifest,
|
||||
restore_user_from_default,
|
||||
sanitize_namespace,
|
||||
set_last_profile,
|
||||
set_quick_select,
|
||||
user_profile_candidates,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
|
||||
RestoreProfileDialog,
|
||||
SaveProfileDialog,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
|
||||
from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actions import (
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
|
||||
WorkSpaceManager,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
|
||||
WorkspaceConnection,
|
||||
workspace_bundle,
|
||||
)
|
||||
@@ -69,14 +68,14 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
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.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||
@@ -87,28 +86,25 @@ logger = bec_logger.logger
|
||||
_PROFILE_NAMESPACE_UNSET = object()
|
||||
|
||||
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
|
||||
StartupProfile = Literal["restore", "skip"] | str | None
|
||||
|
||||
|
||||
class BECDockArea(DockAreaWidget):
|
||||
class AdvancedDockArea(DockAreaWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = [
|
||||
"new",
|
||||
"dock_map",
|
||||
"dock_list",
|
||||
"widget_map",
|
||||
"widget_list",
|
||||
"workspace_is_locked",
|
||||
"lock_workspace",
|
||||
"attach_all",
|
||||
"delete_all",
|
||||
"delete",
|
||||
"set_layout_ratios",
|
||||
"describe_layout",
|
||||
"print_layout_structure",
|
||||
"mode",
|
||||
"mode.setter",
|
||||
"list_profiles",
|
||||
"save_profile",
|
||||
"load_profile",
|
||||
"delete_profile",
|
||||
]
|
||||
|
||||
# Define a signal for mode changes
|
||||
@@ -125,18 +121,21 @@ class BECDockArea(DockAreaWidget):
|
||||
instance_id: str | None = None,
|
||||
auto_save_upon_exit: bool = True,
|
||||
enable_profile_management: bool = True,
|
||||
startup_profile: StartupProfile = "restore",
|
||||
restore_initial_profile: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
self._profile_namespace_hint = profile_namespace
|
||||
self._profile_namespace_auto = auto_profile_namespace
|
||||
self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET
|
||||
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
|
||||
self._instance_id = sanitize_namespace(instance_id) if instance_id else None
|
||||
self._auto_save_upon_exit = auto_save_upon_exit
|
||||
self._profile_management_enabled = enable_profile_management
|
||||
self._startup_profile = self._normalize_startup_profile(startup_profile)
|
||||
self._restore_initial_profile = restore_initial_profile
|
||||
super().__init__(
|
||||
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
|
||||
parent,
|
||||
default_add_direction=default_add_direction,
|
||||
title="Advanced Dock Area",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
@@ -156,16 +155,14 @@ class BECDockArea(DockAreaWidget):
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
# Populate and hook the workspace combo
|
||||
self._refresh_workspace_list()
|
||||
self._current_profile_name = None
|
||||
self._empty_profile_active = False
|
||||
self._empty_profile_consumed = False
|
||||
self._pending_autosave_skip: tuple[str, str] | None = None
|
||||
self._exit_snapshot_written = False
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(
|
||||
self, serialize_from_root=True, root_id="BECDockArea"
|
||||
self, serialize_from_root=True, root_id="AdvancedDockArea"
|
||||
)
|
||||
|
||||
# Developer mode state
|
||||
@@ -173,85 +170,49 @@ class BECDockArea(DockAreaWidget):
|
||||
# Initialize default editable state based on current lock
|
||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||
|
||||
# Sync Developer toggle icon state after initial setup #TODO temporary disable
|
||||
# dev_action = self.toolbar.components.get_action("developer_mode").action
|
||||
# dev_action.setChecked(self._editable)
|
||||
|
||||
# Apply the requested mode after everything is set up
|
||||
self.mode = mode
|
||||
self._fetch_initial_profile()
|
||||
if self._restore_initial_profile:
|
||||
self._fetch_initial_profile()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
|
||||
"""
|
||||
Normalize startup profile values.
|
||||
"""
|
||||
if startup_profile == "":
|
||||
return None
|
||||
return startup_profile
|
||||
|
||||
def _resolve_restore_startup_profile(self) -> str | None:
|
||||
"""
|
||||
Resolve the profile name when startup profile is set to "restore".
|
||||
"""
|
||||
def _fetch_initial_profile(self):
|
||||
# Restore last-used profile if available; otherwise fall back to combo selection
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
namespace = self.profile_namespace
|
||||
|
||||
init_profile = None
|
||||
instance_id = self._last_profile_instance_id()
|
||||
if instance_id:
|
||||
inst_profile = get_last_profile(
|
||||
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
|
||||
)
|
||||
if inst_profile and self._profile_exists(inst_profile, namespace):
|
||||
return inst_profile
|
||||
|
||||
last = get_last_profile(namespace=namespace)
|
||||
if last and self._profile_exists(last, namespace):
|
||||
return last
|
||||
|
||||
combo_text = combo.currentText().strip()
|
||||
if combo_text and self._profile_exists(combo_text, namespace):
|
||||
return combo_text
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_initial_profile(self):
|
||||
startup_profile = self._startup_profile
|
||||
|
||||
if startup_profile == "skip":
|
||||
logger.debug("Skipping startup profile initialization.")
|
||||
return
|
||||
|
||||
if startup_profile == "restore":
|
||||
restored = self._resolve_restore_startup_profile()
|
||||
if restored:
|
||||
self._load_initial_profile(restored)
|
||||
return
|
||||
self._start_empty_workspace()
|
||||
return
|
||||
|
||||
if startup_profile is None:
|
||||
self._start_empty_workspace()
|
||||
return
|
||||
|
||||
self._load_initial_profile(startup_profile)
|
||||
init_profile = inst_profile
|
||||
if not init_profile:
|
||||
last = get_last_profile(namespace=namespace)
|
||||
if last and self._profile_exists(last, namespace):
|
||||
init_profile = last
|
||||
else:
|
||||
text = combo.currentText()
|
||||
init_profile = text if text else None
|
||||
if not init_profile:
|
||||
if self._profile_exists("general", namespace):
|
||||
init_profile = "general"
|
||||
if init_profile:
|
||||
# Defer initial load to the event loop so child widgets exist before state restore.
|
||||
QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile))
|
||||
|
||||
def _load_initial_profile(self, name: str) -> None:
|
||||
"""Load the initial profile."""
|
||||
"""Load the initial profile after construction when the event loop is running."""
|
||||
self.load_profile(name)
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.blockSignals(True)
|
||||
if not self._empty_profile_active:
|
||||
combo.setCurrentText(name)
|
||||
combo.setCurrentText(name)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _start_empty_workspace(self) -> None:
|
||||
"""
|
||||
Initialize the dock area in transient empty-profile mode.
|
||||
"""
|
||||
if (
|
||||
getattr(self, "_current_profile_name", None) is None
|
||||
and not self._empty_profile_consumed
|
||||
):
|
||||
self.delete_all()
|
||||
self._enter_empty_profile_state()
|
||||
|
||||
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
prefs = getattr(dock, "_dock_preferences", {}) or {}
|
||||
if prefs.get("show_settings_action") is None:
|
||||
@@ -270,46 +231,20 @@ class BECDockArea(DockAreaWidget):
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
where: Literal["left", "right", "top", "bottom"] | None = None,
|
||||
on_close: Callable[[CDockWidget, QWidget], None] | None = None,
|
||||
tab_with: CDockWidget | QWidget | str | None = None,
|
||||
relative_to: CDockWidget | QWidget | str | None = None,
|
||||
return_dock: bool = False,
|
||||
show_title_bar: bool | None = None,
|
||||
title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None,
|
||||
show_settings_action: bool | None = None,
|
||||
promote_central: bool = False,
|
||||
object_name: str | None = None,
|
||||
**widget_kwargs,
|
||||
) -> QWidget | BECWidget:
|
||||
) -> QWidget | CDockWidget | BECWidget:
|
||||
"""
|
||||
Create a new widget (or reuse an instance) and add it as a dock.
|
||||
Override the base helper so dock settings are available by default.
|
||||
|
||||
Args:
|
||||
widget(QWidget | str): Instance or registered widget type string.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floatable(bool): Whether the dock is floatable.
|
||||
movable(bool): Whether the dock is movable.
|
||||
start_floating(bool): Whether to start the dock floating.
|
||||
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
|
||||
``relative_to`` is provided without an explicit value).
|
||||
tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside.
|
||||
relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor.
|
||||
When supplied and ``where`` is ``None``, the new dock inherits the
|
||||
anchor's current dock area.
|
||||
show_title_bar(bool | None): Explicitly show or hide the dock area's title bar.
|
||||
title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should
|
||||
remain visible. Provide a mapping of button names (``"float"``,
|
||||
``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans,
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
**widget_kwargs: Additional keyword arguments passed to the widget constructor
|
||||
when creating by type name.
|
||||
|
||||
Returns:
|
||||
BECWidget: The created or reused widget instance.
|
||||
The flag remains user-configurable (pass ``False`` to hide the action).
|
||||
"""
|
||||
if show_settings_action is None:
|
||||
show_settings_action = True
|
||||
@@ -320,13 +255,14 @@ class BECDockArea(DockAreaWidget):
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
where=where,
|
||||
on_close=on_close,
|
||||
tab_with=tab_with,
|
||||
relative_to=relative_to,
|
||||
return_dock=return_dock,
|
||||
show_title_bar=show_title_bar,
|
||||
title_buttons=title_buttons,
|
||||
show_settings_action=show_settings_action,
|
||||
promote_central=promote_central,
|
||||
object_name=object_name,
|
||||
**widget_kwargs,
|
||||
)
|
||||
|
||||
@@ -343,7 +279,7 @@ class BECDockArea(DockAreaWidget):
|
||||
def _setup_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
|
||||
plot_actions = {
|
||||
PLOT_ACTIONS = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
"scatter_waveform": (
|
||||
ScatterWaveform.ICON_NAME,
|
||||
@@ -355,7 +291,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||
}
|
||||
device_actions = {
|
||||
DEVICE_ACTIONS = {
|
||||
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
||||
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
||||
"positioner_box_2D": (
|
||||
@@ -364,7 +300,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"PositionerBox2D",
|
||||
),
|
||||
}
|
||||
util_actions = {
|
||||
UTIL_ACTIONS = {
|
||||
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
||||
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
||||
"progress_bar": (
|
||||
@@ -373,7 +309,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"RingProgressBar",
|
||||
),
|
||||
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
||||
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||
}
|
||||
@@ -396,9 +332,9 @@ class BECDockArea(DockAreaWidget):
|
||||
b.add_action(key)
|
||||
self.toolbar.add_bundle(b)
|
||||
|
||||
_build_menu("menu_plots", "Add Plot ", plot_actions)
|
||||
_build_menu("menu_devices", "Add Device Control ", device_actions)
|
||||
_build_menu("menu_utils", "Add Utils ", util_actions)
|
||||
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
|
||||
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
|
||||
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
@@ -422,14 +358,14 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
_build_flat_bundles("plots", plot_actions)
|
||||
_build_flat_bundles("devices", device_actions)
|
||||
_build_flat_bundles("utils", util_actions)
|
||||
_build_flat_bundles("plots", PLOT_ACTIONS)
|
||||
_build_flat_bundles("devices", DEVICE_ACTIONS)
|
||||
_build_flat_bundles("utils", UTIL_ACTIONS)
|
||||
|
||||
# Workspace
|
||||
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
|
||||
spacer = QWidget(parent=self.toolbar.components.toolbar)
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
spacer_bundle.add_action("spacer")
|
||||
self.toolbar.add_bundle(spacer_bundle)
|
||||
@@ -468,15 +404,16 @@ class BECDockArea(DockAreaWidget):
|
||||
bda.add_action("attach_all")
|
||||
bda.add_action("screenshot")
|
||||
bda.add_action("dark_mode")
|
||||
# bda.add_action("developer_mode") #TODO temporary disable
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
self._apply_toolbar_layout()
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": plot_actions,
|
||||
"menu_devices": device_actions,
|
||||
"menu_utils": util_actions,
|
||||
"menu_plots": PLOT_ACTIONS,
|
||||
"menu_devices": DEVICE_ACTIONS,
|
||||
"menu_utils": UTIL_ACTIONS,
|
||||
}
|
||||
|
||||
def _hook_toolbar(self):
|
||||
@@ -496,7 +433,10 @@ class BECDockArea(DockAreaWidget):
|
||||
elif key == "bec_shell":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type: self.new(
|
||||
widget=t, closable=True, show_settings_action=False
|
||||
widget=t,
|
||||
closable=True,
|
||||
startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}",
|
||||
show_settings_action=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -523,7 +463,7 @@ class BECDockArea(DockAreaWidget):
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.workspace_is_locked = not editable
|
||||
self.lock_workspace = not editable
|
||||
self._editable = editable
|
||||
|
||||
if self._profile_management_enabled:
|
||||
@@ -537,7 +477,7 @@ class BECDockArea(DockAreaWidget):
|
||||
# Workspace Management
|
||||
################################################################################
|
||||
@SafeProperty(bool)
|
||||
def workspace_is_locked(self) -> bool:
|
||||
def lock_workspace(self) -> bool:
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
@@ -546,8 +486,8 @@ class BECDockArea(DockAreaWidget):
|
||||
"""
|
||||
return self._locked
|
||||
|
||||
@workspace_is_locked.setter
|
||||
def workspace_is_locked(self, value: bool):
|
||||
@lock_workspace.setter
|
||||
def lock_workspace(self, value: bool):
|
||||
"""
|
||||
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
|
||||
|
||||
@@ -587,7 +527,7 @@ class BECDockArea(DockAreaWidget):
|
||||
if not candidate:
|
||||
candidate = self.__class__.__name__
|
||||
|
||||
resolved = slugify.slugify(candidate, separator="_") if candidate else None
|
||||
resolved = sanitize_namespace(candidate) if candidate else None
|
||||
if not resolved:
|
||||
resolved = "general"
|
||||
self._profile_namespace_resolved = resolved # type: ignore[assignment]
|
||||
@@ -598,6 +538,13 @@ class BECDockArea(DockAreaWidget):
|
||||
"""Namespace used to scope user/default profile files for this dock area."""
|
||||
return self._resolve_profile_namespace()
|
||||
|
||||
def _active_profile_name_or_default(self) -> str:
|
||||
name = getattr(self, "_current_profile_name", None)
|
||||
if not name:
|
||||
name = "general"
|
||||
self._current_profile_name = name
|
||||
return name
|
||||
|
||||
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
||||
return any(
|
||||
os.path.exists(path) for path in user_profile_candidates(name, namespace)
|
||||
@@ -622,89 +569,8 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
logger.info(f"Workspace snapshot written to settings: {settings.fileName()}")
|
||||
|
||||
def _write_profile_settings(
|
||||
self,
|
||||
name: str,
|
||||
namespace: str | None,
|
||||
*,
|
||||
write_default: bool = True,
|
||||
write_user: bool = True,
|
||||
save_preview: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Write profile settings to default and/or user settings files.
|
||||
|
||||
Args:
|
||||
name: The profile name.
|
||||
namespace: The profile namespace.
|
||||
write_default: Whether to write to the default settings file.
|
||||
write_user: Whether to write to the user settings file.
|
||||
save_preview: Whether to save a screenshot preview.
|
||||
"""
|
||||
if write_default:
|
||||
ds = open_default_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(ds, save_preview=save_preview)
|
||||
if not ds.value(SETTINGS_KEYS["created_at"], ""):
|
||||
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||
|
||||
if write_user:
|
||||
us = open_user_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(us, save_preview=save_preview)
|
||||
if not us.value(SETTINGS_KEYS["created_at"], ""):
|
||||
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
us.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||
|
||||
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
||||
"""
|
||||
Finalize a profile change by updating state and refreshing the UI.
|
||||
|
||||
Args:
|
||||
name: The profile name.
|
||||
namespace: The profile namespace.
|
||||
"""
|
||||
self._empty_profile_active = False
|
||||
self._empty_profile_consumed = True
|
||||
self._current_profile_name = name
|
||||
self.profile_changed.emit(name)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.refresh_profiles(active_profile=name)
|
||||
|
||||
def _enter_empty_profile_state(self) -> None:
|
||||
"""
|
||||
Switch to the transient empty workspace state.
|
||||
|
||||
In this mode there is no active profile name, the toolbar shows an
|
||||
explicit blank profile entry, and no autosave on shutdown is performed.
|
||||
"""
|
||||
self._empty_profile_active = True
|
||||
self._current_profile_name = None
|
||||
self._pending_autosave_skip = None
|
||||
self._refresh_workspace_list()
|
||||
|
||||
@SafeSlot()
|
||||
def list_profiles(self) -> list[str]:
|
||||
"""
|
||||
List available workspace profiles in the current namespace.
|
||||
|
||||
Returns:
|
||||
list[str]: List of profile names.
|
||||
"""
|
||||
namespace = self.profile_namespace
|
||||
return list_profiles(namespace)
|
||||
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
def save_profile(
|
||||
self,
|
||||
name: str | None = None,
|
||||
*,
|
||||
show_dialog: bool = False,
|
||||
quick_select: bool | None = None,
|
||||
):
|
||||
def save_profile(self, name: str | None = None):
|
||||
"""
|
||||
Save the current workspace profile.
|
||||
|
||||
@@ -716,124 +582,86 @@ class BECDockArea(DockAreaWidget):
|
||||
Read-only bundled profiles cannot be overwritten.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to save. If None and show_dialog is True,
|
||||
prompts the user.
|
||||
show_dialog (bool): If True, shows the SaveProfileDialog for user interaction.
|
||||
If False (default), saves directly without user interaction (useful for CLI usage).
|
||||
quick_select (bool | None): Whether to include the profile in quick selection.
|
||||
If None (default), uses the existing value or True for new profiles.
|
||||
Only used when show_dialog is False; otherwise the dialog provides the value.
|
||||
name (str | None): The name of the profile to save. If None, prompts the user.
|
||||
"""
|
||||
|
||||
namespace = self.profile_namespace
|
||||
current_profile = getattr(self, "_current_profile_name", "") or ""
|
||||
|
||||
def _profile_exists(profile_name: str) -> bool:
|
||||
return profile_origin(profile_name, namespace=namespace) != "unknown"
|
||||
|
||||
# Determine final values either from dialog or directly
|
||||
if show_dialog:
|
||||
initial_name = name or ""
|
||||
quickselect_default = is_quick_select(name, namespace=namespace) if name else True
|
||||
initial_name = name or ""
|
||||
quickselect_default = is_quick_select(name, namespace=namespace) if name else False
|
||||
|
||||
dialog = SaveProfileDialog(
|
||||
self,
|
||||
current_name=initial_name,
|
||||
current_profile_name=current_profile,
|
||||
name_exists=_profile_exists,
|
||||
profile_origin=lambda n: profile_origin(n, namespace=namespace),
|
||||
origin_label=lambda n: profile_origin_display(n, namespace=namespace),
|
||||
quick_select_checked=quickselect_default,
|
||||
)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
name = dialog.get_profile_name()
|
||||
quickselect = dialog.is_quick_select()
|
||||
overwrite_existing = dialog.overwrite_existing
|
||||
else:
|
||||
# CLI / programmatic usage - no dialog
|
||||
if not name:
|
||||
logger.warning("save_profile called without name and show_dialog=False")
|
||||
return
|
||||
|
||||
# Determine quick_select value
|
||||
if quick_select is None:
|
||||
# Use existing value if profile exists, otherwise default to True
|
||||
quickselect = (
|
||||
is_quick_select(name, namespace=namespace) if _profile_exists(name) else True
|
||||
)
|
||||
else:
|
||||
quickselect = quick_select
|
||||
|
||||
# For programmatic saves, check if profile is read-only
|
||||
origin = profile_origin(name, namespace=namespace)
|
||||
if origin in {"module", "plugin"}:
|
||||
logger.warning(f"Cannot save to read-only profile '{name}' (origin: {origin})")
|
||||
return
|
||||
|
||||
# Overwrite existing settings profile when saving programmatically
|
||||
overwrite_existing = origin == "settings"
|
||||
current_profile = getattr(self, "_current_profile_name", "") or ""
|
||||
dialog = SaveProfileDialog(
|
||||
self,
|
||||
current_name=initial_name,
|
||||
current_profile_name=current_profile,
|
||||
name_exists=_profile_exists,
|
||||
profile_origin=lambda n: profile_origin(n, namespace=namespace),
|
||||
origin_label=lambda n: profile_origin_display(n, namespace=namespace),
|
||||
quick_select_checked=quickselect_default,
|
||||
)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
name = dialog.get_profile_name()
|
||||
quickselect = dialog.is_quick_select()
|
||||
origin_before_save = profile_origin(name, namespace=namespace)
|
||||
overwrite_default = overwrite_existing and origin_before_save == "settings"
|
||||
|
||||
# Display saving placeholder in toolbar
|
||||
overwrite_default = dialog.overwrite_existing and origin_before_save == "settings"
|
||||
# Display saving placeholder
|
||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
workspace_combo.blockSignals(True)
|
||||
workspace_combo.insertItem(0, f"{name}-saving")
|
||||
workspace_combo.setCurrentIndex(0)
|
||||
workspace_combo.blockSignals(False)
|
||||
|
||||
# Write to default and/or user settings
|
||||
# Create or update default copy controlled by overwrite flag
|
||||
should_write_default = overwrite_default or not any(
|
||||
os.path.exists(path) for path in default_profile_candidates(name, namespace)
|
||||
)
|
||||
self._write_profile_settings(
|
||||
name, namespace, write_default=should_write_default, write_user=True
|
||||
)
|
||||
if should_write_default:
|
||||
ds = open_default_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(ds)
|
||||
if not ds.value(SETTINGS_KEYS["created_at"], ""):
|
||||
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
# Ensure new profiles are not quick-select by default
|
||||
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
ds.setValue(SETTINGS_KEYS["is_quick_select"], False)
|
||||
|
||||
set_quick_select(name, quickselect, namespace=namespace)
|
||||
# Always (over)write the user copy
|
||||
us = open_user_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(us)
|
||||
if not us.value(SETTINGS_KEYS["created_at"], ""):
|
||||
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
# Ensure new profiles are not quick-select by default (only if missing)
|
||||
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
us.setValue(SETTINGS_KEYS["is_quick_select"], False)
|
||||
|
||||
# set quick select
|
||||
if quickselect:
|
||||
set_quick_select(name, quickselect, namespace=namespace)
|
||||
|
||||
self._refresh_workspace_list()
|
||||
if current_profile and current_profile != name and not overwrite_existing:
|
||||
if current_profile and current_profile != name and not dialog.overwrite_existing:
|
||||
self._pending_autosave_skip = (current_profile, name)
|
||||
else:
|
||||
self._pending_autosave_skip = None
|
||||
workspace_combo.setCurrentText(name)
|
||||
self._finalize_profile_change(name, namespace)
|
||||
self._current_profile_name = name
|
||||
self.profile_changed.emit(name)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.refresh_profiles(active_profile=name)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
def save_profile_dialog(self, name: str | None = None):
|
||||
"""
|
||||
Save the current workspace profile with a dialog prompt.
|
||||
|
||||
This is a convenience method for UI usage (toolbar, dialogs) that
|
||||
always shows the SaveProfileDialog. For programmatic/CLI usage,
|
||||
use save_profile() directly.
|
||||
|
||||
Args:
|
||||
name (str | None): Optional initial name to populate in the dialog.
|
||||
"""
|
||||
self.save_profile(name, show_dialog=True)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
def load_profile(self, name: str | None = None):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Before switching, persist the current profile to the user copy.
|
||||
Prefer loading the user copy; fall back to the default copy.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
"""
|
||||
if name == "":
|
||||
return
|
||||
|
||||
if not name: # Gui fallback if the name is not provided
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||
@@ -857,14 +685,9 @@ class BECDockArea(DockAreaWidget):
|
||||
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
|
||||
settings = open_default_settings(name, namespace=namespace)
|
||||
if settings is None:
|
||||
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
||||
self.delete_all()
|
||||
self.save_profile(name, show_dialog=False, quick_select=True)
|
||||
QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.")
|
||||
return
|
||||
|
||||
# Clear existing docks and remove all widgets
|
||||
self.delete_all()
|
||||
|
||||
# Rebuild widgets and restore states
|
||||
for item in read_manifest(settings):
|
||||
obj_name = item["object_name"]
|
||||
@@ -893,7 +716,11 @@ class BECDockArea(DockAreaWidget):
|
||||
self.state_manager.load_state(settings=settings)
|
||||
self._set_editable(self._editable)
|
||||
|
||||
self._finalize_profile_change(name, namespace)
|
||||
self._current_profile_name = name
|
||||
self.profile_changed.emit(name)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.refresh_profiles(active_profile=name)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@@ -927,82 +754,37 @@ class BECDockArea(DockAreaWidget):
|
||||
self.load_profile(target)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_profile(self, name: str | None = None, show_dialog: bool = False) -> bool:
|
||||
def delete_profile(self):
|
||||
"""
|
||||
Delete a workspace profile.
|
||||
|
||||
Args:
|
||||
name: The name of the profile to delete. If None, uses the currently
|
||||
selected profile from the toolbar combo box (for UI usage).
|
||||
show_dialog: If True, show confirmation dialog before deletion.
|
||||
Defaults to False for CLI/programmatic usage.
|
||||
|
||||
Returns:
|
||||
bool: True if the profile was deleted, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If the profile is read-only or doesn't exist (when show_dialog=False).
|
||||
|
||||
Delete the currently selected workspace profile file and refresh the combo list.
|
||||
"""
|
||||
# Resolve profile name
|
||||
if name is None:
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
name = combo.currentText()
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
name = combo.currentText()
|
||||
if not name:
|
||||
if show_dialog:
|
||||
return False
|
||||
raise ValueError("No profile name provided.")
|
||||
return
|
||||
|
||||
# Protect bundled/module/plugin profiles from deletion
|
||||
if is_profile_read_only(name, namespace=self.profile_namespace):
|
||||
QMessageBox.information(
|
||||
self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted."
|
||||
)
|
||||
return
|
||||
|
||||
# Confirm deletion for regular profiles
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||
f"This action cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
namespace = self.profile_namespace
|
||||
|
||||
# Check if profile is read-only
|
||||
if is_profile_read_only(name, namespace=namespace):
|
||||
if show_dialog:
|
||||
QMessageBox.information(
|
||||
self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted."
|
||||
)
|
||||
return False
|
||||
raise ValueError(f"Profile '{name}' is read-only and cannot be deleted.")
|
||||
|
||||
# Confirm deletion if dialog is enabled
|
||||
if show_dialog:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||
f"This action cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return False
|
||||
|
||||
# Perform deletion
|
||||
try:
|
||||
removed = delete_profile_files(name, namespace=namespace)
|
||||
except OSError as exc:
|
||||
if show_dialog:
|
||||
QMessageBox.warning(
|
||||
self, "Delete Profile", f"Failed to delete profile '{name}': {exc}"
|
||||
)
|
||||
return False
|
||||
raise ValueError(f"Failed to delete profile '{name}': {exc}") from exc
|
||||
|
||||
if not removed:
|
||||
if show_dialog:
|
||||
QMessageBox.information(
|
||||
self, "Delete Profile", "No writable profile files were found to delete."
|
||||
)
|
||||
return False
|
||||
raise ValueError(f"No writable profile files found for '{name}'.")
|
||||
|
||||
# Clear current profile if it was deleted
|
||||
if getattr(self, "_current_profile_name", None) == name:
|
||||
self._current_profile_name = None
|
||||
|
||||
# Refresh the workspace list
|
||||
delete_profile_files(name, namespace=namespace)
|
||||
self._refresh_workspace_list()
|
||||
return True
|
||||
|
||||
def _refresh_workspace_list(self):
|
||||
"""
|
||||
@@ -1010,36 +792,25 @@ class BECDockArea(DockAreaWidget):
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
active_profile = getattr(self, "_current_profile_name", None)
|
||||
empty_profile_active = bool(getattr(self, "_empty_profile_active", False))
|
||||
namespace = self.profile_namespace
|
||||
if hasattr(combo, "set_quick_profile_provider"):
|
||||
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
|
||||
if hasattr(combo, "refresh_profiles"):
|
||||
if empty_profile_active:
|
||||
combo.refresh_profiles(active_profile, show_empty_profile=True)
|
||||
else:
|
||||
combo.refresh_profiles(active_profile)
|
||||
combo.refresh_profiles(active_profile)
|
||||
else:
|
||||
# Fallback for regular QComboBox
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
quick_profiles = list_quick_profiles(namespace=namespace)
|
||||
items = [""] if empty_profile_active else []
|
||||
items.extend(quick_profiles)
|
||||
items = list(quick_profiles)
|
||||
if active_profile and active_profile not in items:
|
||||
items.insert(0, active_profile)
|
||||
combo.addItems(items)
|
||||
if empty_profile_active:
|
||||
idx = combo.findText("")
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
elif active_profile:
|
||||
if active_profile:
|
||||
idx = combo.findText(active_profile)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
if empty_profile_active:
|
||||
combo.setToolTip("Unsaved empty workspace")
|
||||
elif active_profile and active_profile not in quick_profiles:
|
||||
if active_profile and active_profile not in quick_profiles:
|
||||
combo.setToolTip("Active profile is not in quick select")
|
||||
else:
|
||||
combo.setToolTip("")
|
||||
@@ -1144,16 +915,7 @@ class BECDockArea(DockAreaWidget):
|
||||
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
|
||||
return
|
||||
|
||||
if getattr(self, "_empty_profile_active", False):
|
||||
logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace")
|
||||
self._exit_snapshot_written = True
|
||||
return
|
||||
|
||||
name = getattr(self, "_current_profile_name", None)
|
||||
if not name:
|
||||
logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)")
|
||||
self._exit_snapshot_written = True
|
||||
return
|
||||
name = self._active_profile_name_or_default()
|
||||
|
||||
namespace = self.profile_namespace
|
||||
settings = open_user_settings(name, namespace=namespace)
|
||||
@@ -1161,33 +923,6 @@ class BECDockArea(DockAreaWidget):
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
self._exit_snapshot_written = True
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app):
|
||||
"""Register Dock Area components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
|
||||
step_ids = []
|
||||
|
||||
# Register Dock Area toolbar
|
||||
def get_dock_toolbar():
|
||||
main_app.set_current("dock_area")
|
||||
return (self.toolbar, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_dock_toolbar,
|
||||
title="Dock Area Toolbar",
|
||||
text="Use this toolbar to add widgets, manage workspaces, save and load profiles, and control the layout of your workspace.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
@@ -1211,7 +946,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True)
|
||||
ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True)
|
||||
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
|
||||
from shiboken6 import isValid
|
||||
@@ -14,7 +14,6 @@ from shiboken6 import isValid
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.containers.qt_ads import (
|
||||
@@ -55,7 +54,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
"widget_list",
|
||||
"attach_all",
|
||||
"delete_all",
|
||||
"delete",
|
||||
"set_layout_ratios",
|
||||
"describe_layout",
|
||||
"print_layout_structure",
|
||||
@@ -113,7 +111,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
)
|
||||
|
||||
self._root_layout.addWidget(self.dock_manager, 1)
|
||||
self._install_manager_parent_guards()
|
||||
|
||||
################################################################################
|
||||
# Dock Utility Helpers
|
||||
@@ -256,54 +253,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
|
||||
return lambda dock: self._default_close_handler(dock, widget)
|
||||
|
||||
def _install_manager_parent_guards(self) -> None:
|
||||
"""
|
||||
Track ADS structural changes so drag/drop-created tab areas keep stable parenting.
|
||||
"""
|
||||
self.dock_manager.dockAreaCreated.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.dockWidgetAdded.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.stateRestored.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.restoringState.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._normalize_all_dock_parents)
|
||||
self._normalize_all_dock_parents()
|
||||
|
||||
def _iter_all_dock_areas(self) -> list[CDockAreaWidget]:
|
||||
"""Return all dock areas from all known dock containers."""
|
||||
areas: list[CDockAreaWidget] = []
|
||||
for i in range(self.dock_manager.dockAreaCount()):
|
||||
area = self.dock_manager.dockArea(i)
|
||||
if area is None or not isValid(area):
|
||||
continue
|
||||
areas.append(area)
|
||||
return areas
|
||||
|
||||
def _connect_dock_area_parent_guards(self) -> None:
|
||||
"""Bind area-level tab/view events to parent normalization."""
|
||||
for area in self._iter_all_dock_areas():
|
||||
try:
|
||||
area.currentChanged.connect(
|
||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
||||
)
|
||||
area.viewToggled.connect(
|
||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
||||
)
|
||||
except TypeError:
|
||||
area.currentChanged.connect(self._normalize_all_dock_parents)
|
||||
area.viewToggled.connect(self._normalize_all_dock_parents)
|
||||
|
||||
def _normalize_all_dock_parents(self, *_args) -> None:
|
||||
"""
|
||||
Ensure each dock has a stable parent after tab switches, re-docking, or restore.
|
||||
"""
|
||||
self._connect_dock_area_parent_guards()
|
||||
for dock in self.dock_list():
|
||||
if dock is None or not isValid(dock):
|
||||
continue
|
||||
area_widget = dock.dockAreaWidget()
|
||||
target_parent = area_widget if area_widget is not None else self.dock_manager
|
||||
if dock.parent() is not target_parent:
|
||||
dock.setParent(target_parent)
|
||||
|
||||
def _make_dock(
|
||||
self,
|
||||
widget: QWidget,
|
||||
@@ -352,13 +301,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
|
||||
dock = CDockWidget(self.dock_manager, widget.objectName(), self)
|
||||
dock.setWidget(widget)
|
||||
widget_min_size = widget.minimumSize()
|
||||
widget_min_hint = widget.minimumSizeHint()
|
||||
dock_min_size = QSize(
|
||||
max(widget_min_size.width(), widget_min_hint.width()),
|
||||
max(widget_min_size.height(), widget_min_hint.height()),
|
||||
)
|
||||
dock.setMinimumSize(dock_min_size)
|
||||
dock._dock_preferences = dict(dock_preferences or {})
|
||||
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True)
|
||||
@@ -381,9 +323,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
dock.setMinimumSizeHintMode(
|
||||
CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize
|
||||
)
|
||||
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||
dock_area_widget = None
|
||||
if tab_with is not None:
|
||||
if not isValid(tab_with):
|
||||
@@ -406,7 +346,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
self._apply_floating_state_to_dock(dock, floating_state)
|
||||
if resolved_icon is not None:
|
||||
dock.setIcon(resolved_icon)
|
||||
self._normalize_all_dock_parents()
|
||||
return dock
|
||||
|
||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||
@@ -1249,7 +1188,8 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if button is not None:
|
||||
button.setVisible(bool(visible))
|
||||
|
||||
apply()
|
||||
# single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None:
|
||||
"""
|
||||
@@ -1287,7 +1227,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
promote_central: bool = False,
|
||||
dock_icon: QIcon | None = None,
|
||||
apply_widget_icon: bool = True,
|
||||
object_name: str | None = None,
|
||||
**widget_kwargs,
|
||||
) -> QWidget | CDockWidget | BECWidget:
|
||||
"""
|
||||
@@ -1315,7 +1254,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -1323,9 +1262,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
the widget's ``ICON_NAME`` attribute is used when available.
|
||||
apply_widget_icon(bool): When False, skip automatically resolving the icon from
|
||||
the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
**widget_kwargs: Additional keyword arguments passed to the widget constructor
|
||||
when creating by type name.
|
||||
|
||||
Returns:
|
||||
The widget instance by default, or the created `CDockWidget` when `return_dock` is True.
|
||||
@@ -1337,9 +1273,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
)
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(
|
||||
widget_type=widget, parent=self, object_name=object_name, **widget_kwargs
|
||||
),
|
||||
widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs),
|
||||
)
|
||||
|
||||
spec = self._build_creation_spec(
|
||||
@@ -1361,7 +1295,11 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
apply_widget_icon=apply_widget_icon,
|
||||
)
|
||||
|
||||
self._create_dock_from_spec(spec)
|
||||
def _on_name_established(_name: str) -> None:
|
||||
# Defer creation so BECConnector sibling name enforcement has completed.
|
||||
QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec))
|
||||
|
||||
widget.name_established.connect(_on_name_established)
|
||||
return widget
|
||||
|
||||
spec = self._build_creation_spec(
|
||||
@@ -1385,40 +1323,37 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
dock = self._create_dock_from_spec(spec)
|
||||
return dock if return_dock else widget
|
||||
|
||||
def _iter_all_docks(self) -> list[CDockWidget]:
|
||||
"""Return all docks, including those hosted in floating containers."""
|
||||
docks = list(self.dock_manager.dockWidgets())
|
||||
seen = {id(d) for d in docks}
|
||||
for container in self.dock_manager.floatingWidgets():
|
||||
if container is None:
|
||||
continue
|
||||
for dock in container.dockWidgets():
|
||||
if dock is None:
|
||||
continue
|
||||
if id(dock) in seen:
|
||||
continue
|
||||
docks.append(dock)
|
||||
seen.add(id(dock))
|
||||
return docks
|
||||
|
||||
def dock_map(self) -> dict[str, CDockWidget]:
|
||||
"""Return the dock widgets map as dictionary with names as keys."""
|
||||
return self.dock_manager.dockWidgetsMap()
|
||||
return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()}
|
||||
|
||||
def dock_list(self) -> list[CDockWidget]:
|
||||
"""Return the list of dock widgets."""
|
||||
return list(self.dock_map().values())
|
||||
return self._iter_all_docks()
|
||||
|
||||
def widget_map(self, bec_widgets_only: bool = True) -> dict[str, QWidget]:
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
def widget_map(self) -> dict[str, QWidget]:
|
||||
"""Return a dictionary mapping widget names to their corresponding widgets."""
|
||||
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
widgets: dict[str, QWidget] = {}
|
||||
for dock in self.dock_list():
|
||||
widget = dock.widget()
|
||||
if not isinstance(widget, QWidget):
|
||||
continue
|
||||
if bec_widgets_only and not isinstance(widget, BECConnector):
|
||||
continue
|
||||
widgets[dock.objectName()] = widget
|
||||
return widgets
|
||||
|
||||
def widget_list(self, bec_widgets_only: bool = True) -> list[QWidget]:
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
return list(self.widget_map(bec_widgets_only=bec_widgets_only).values())
|
||||
def widget_list(self) -> list[QWidget]:
|
||||
"""Return a list of all widgets contained in the dock area."""
|
||||
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
@@ -1434,42 +1369,17 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, dock, target
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def delete(self, object_name: str) -> bool:
|
||||
"""
|
||||
Remove a widget from the dock area by its object name.
|
||||
|
||||
Args:
|
||||
object_name: The object name of the widget to remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the widget was found and removed, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If no widget with the given object name is found.
|
||||
|
||||
Example:
|
||||
>>> dock_area.delete("my_widget")
|
||||
True
|
||||
"""
|
||||
dock_map = self.dock_map()
|
||||
dock = dock_map.get(object_name)
|
||||
if dock is None:
|
||||
raise ValueError(f"No widget found with object name '{object_name}'.")
|
||||
self._delete_dock(dock)
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def delete_all(self):
|
||||
"""Delete all docks and their associated widgets."""
|
||||
for dock in self.dock_list():
|
||||
for dock in list(self.dock_manager.dockWidgets()):
|
||||
self._delete_dock(dock)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Utilities for managing BECDockArea profiles stored in INI files.
|
||||
Utilities for managing AdvancedDockArea profiles stored in INI files.
|
||||
|
||||
Policy:
|
||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
||||
@@ -10,21 +10,20 @@ Policy:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import slugify
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QByteArray, QDateTime, QSettings, QTimeZone
|
||||
from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -36,12 +35,12 @@ ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||
|
||||
def module_profiles_dir() -> str:
|
||||
"""
|
||||
Return the built-in BECDockArea profiles directory bundled with the module.
|
||||
Return the built-in AdvancedDockArea profiles directory bundled with the module.
|
||||
|
||||
Returns:
|
||||
str: Absolute path of the read-only module profiles directory.
|
||||
"""
|
||||
return os.path.join(MODULE_PATH, "containers", "dock_area", "profiles")
|
||||
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -115,16 +114,35 @@ def _settings_profiles_root() -> str:
|
||||
str: Absolute path to the profiles root. The directory is created if missing.
|
||||
"""
|
||||
client = BECClient()
|
||||
bec_widgets_settings = client._service_config.config.get("widgets_settings")
|
||||
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
|
||||
bec_widgets_setting_path = (
|
||||
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
|
||||
)
|
||||
default_path = os.path.join(bec_widgets_setting_path, "profiles")
|
||||
root = os.path.expanduser(os.environ.get("BECWIDGETS_PROFILE_DIR", default_path))
|
||||
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def sanitize_namespace(namespace: str | None) -> str | None:
|
||||
"""
|
||||
Clean user-provided namespace labels for filesystem compatibility.
|
||||
|
||||
Args:
|
||||
namespace (str | None): Arbitrary namespace identifier supplied by the caller.
|
||||
|
||||
Returns:
|
||||
str | None: Sanitized namespace containing only safe characters, or ``None``
|
||||
when the input is empty.
|
||||
"""
|
||||
if not namespace:
|
||||
return None
|
||||
ns = namespace.strip()
|
||||
if not ns:
|
||||
return None
|
||||
return re.sub(r"[^0-9A-Za-z._-]+", "_", ns)
|
||||
|
||||
|
||||
def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
"""
|
||||
Build (and ensure) the directory that holds profiles for a namespace segment.
|
||||
@@ -137,8 +155,8 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
str: Absolute directory path for the requested segment/namespace pair.
|
||||
"""
|
||||
base = os.path.join(_settings_profiles_root(), segment)
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
|
||||
ns = sanitize_namespace(namespace)
|
||||
path = os.path.join(base, ns) if ns else base
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
@@ -154,7 +172,7 @@ def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
|
||||
Returns:
|
||||
list[str]: Ordered list of candidate user profile paths (.ini files).
|
||||
"""
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
ns = sanitize_namespace(namespace)
|
||||
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
|
||||
if not ns:
|
||||
return [primary]
|
||||
@@ -173,7 +191,7 @@ def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
|
||||
Returns:
|
||||
list[str]: Ordered list of candidate default profile paths (.ini files).
|
||||
"""
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
ns = sanitize_namespace(namespace)
|
||||
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
|
||||
if not ns:
|
||||
return [primary]
|
||||
@@ -452,7 +470,7 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
||||
Returns:
|
||||
list[str]: Sorted unique profile names.
|
||||
"""
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
ns = sanitize_namespace(namespace)
|
||||
|
||||
def _collect_from(directory: str) -> set[str]:
|
||||
if not os.path.isdir(directory):
|
||||
@@ -553,11 +571,11 @@ def _last_profile_key(namespace: str | None, instance: str | None = None) -> str
|
||||
Returns:
|
||||
str: Scoped key string.
|
||||
"""
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
ns = sanitize_namespace(namespace)
|
||||
key = SETTINGS_KEYS["last_profile"]
|
||||
if ns:
|
||||
key = f"{key}/{ns}"
|
||||
inst = slugify.slugify(instance, separator="_") if instance else ""
|
||||
inst = sanitize_namespace(instance) if instance else ""
|
||||
if inst:
|
||||
key = f"{key}@{inst}"
|
||||
return key
|
||||
@@ -627,7 +645,7 @@ def now_iso_utc() -> str:
|
||||
Returns:
|
||||
str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``).
|
||||
"""
|
||||
return QDateTime.currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
return QDateTime.currentDateTimeUtc().toString(Qt.ISODate)
|
||||
|
||||
|
||||
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
|
||||
@@ -843,9 +861,7 @@ def _file_modified_iso(path: str) -> str:
|
||||
"""
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
return QDateTime.fromSecsSinceEpoch(int(mtime), QTimeZone.utc()).toString(
|
||||
"yyyy-MM-ddTHH:mm:ssZ"
|
||||
)
|
||||
return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate)
|
||||
except Exception:
|
||||
return now_iso_utc()
|
||||
|
||||
@@ -28,7 +28,8 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
delete_profile_files,
|
||||
get_profile_info,
|
||||
is_quick_select,
|
||||
list_profiles,
|
||||
@@ -329,8 +330,8 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
)
|
||||
return
|
||||
|
||||
self.target_widget.save_profile_dialog()
|
||||
# BECDockArea will emit profile_changed which will trigger table refresh,
|
||||
self.target_widget.save_profile()
|
||||
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
|
||||
# but ensure the UI stays in sync even if the signal is delayed.
|
||||
self.render_table()
|
||||
current = getattr(self.target_widget, "_current_profile_name", None)
|
||||
@@ -340,35 +341,55 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot(str)
|
||||
def delete_profile(self, profile_name: str):
|
||||
"""
|
||||
Delete a profile by delegating to the target widget's delete_profile method.
|
||||
|
||||
Args:
|
||||
profile_name: The name of the profile to delete.
|
||||
"""
|
||||
if self.target_widget is None or not hasattr(self.target_widget, "delete_profile"):
|
||||
QMessageBox.warning(
|
||||
self, "Delete Profile", "No target widget available for profile deletion."
|
||||
info = get_profile_info(profile_name, namespace=self.profile_namespace)
|
||||
if info.is_read_only:
|
||||
QMessageBox.information(
|
||||
self, "Delete Profile", "This profile is read-only and cannot be deleted."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = self.target_widget.delete_profile(profile_name, show_dialog=True)
|
||||
except ValueError:
|
||||
# Error was already handled by target widget's dialog
|
||||
result = False
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
(
|
||||
f"Delete the profile '{profile_name}'?\n\n"
|
||||
"This will remove both the user and default copies."
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
if result:
|
||||
# Refresh our table and select next profile
|
||||
self.render_table()
|
||||
remaining_profiles = list_profiles(namespace=self.profile_namespace)
|
||||
if remaining_profiles:
|
||||
next_profile = remaining_profiles[0]
|
||||
self._select_by_name(next_profile)
|
||||
self._show_profile_details(next_profile)
|
||||
else:
|
||||
self.profile_details_tree.clear()
|
||||
self.screenshot_label.setPixmap(QPixmap())
|
||||
try:
|
||||
removed = delete_profile_files(profile_name, namespace=self.profile_namespace)
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(
|
||||
self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
if not removed:
|
||||
QMessageBox.information(
|
||||
self, "Delete Profile", "No writable profile files were found to delete."
|
||||
)
|
||||
return
|
||||
|
||||
if self.target_widget is not None:
|
||||
if getattr(self.target_widget, "_current_profile_name", None) == profile_name:
|
||||
self.target_widget._current_profile_name = None
|
||||
if hasattr(self.target_widget, "_refresh_workspace_list"):
|
||||
self.target_widget._refresh_workspace_list()
|
||||
|
||||
self.render_table()
|
||||
remaining_profiles = list_profiles(namespace=self.profile_namespace)
|
||||
if remaining_profiles:
|
||||
next_profile = remaining_profiles[0]
|
||||
self._select_by_name(next_profile)
|
||||
self._show_profile_details(next_profile)
|
||||
else:
|
||||
self.profile_details_tree.clear()
|
||||
self.screenshot_label.setPixmap(QPixmap())
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
@@ -381,7 +402,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
scaled = pm.scaled(
|
||||
self.screenshot_label.width() or 800,
|
||||
self.screenshot_label.height() or 450,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
self.screenshot_label.setPixmap(scaled)
|
||||
@@ -10,7 +10,7 @@ from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import list_quick_profiles
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles
|
||||
|
||||
|
||||
class ProfileComboBox(QComboBox):
|
||||
@@ -24,15 +24,12 @@ class ProfileComboBox(QComboBox):
|
||||
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
||||
self._quick_provider = provider
|
||||
|
||||
def refresh_profiles(
|
||||
self, active_profile: str | None = None, show_empty_profile: bool = False
|
||||
) -> None:
|
||||
def refresh_profiles(self, active_profile: str | None = None):
|
||||
"""
|
||||
Refresh the profile list and ensure the active profile is visible.
|
||||
|
||||
Args:
|
||||
active_profile(str | None): The currently active profile name.
|
||||
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
||||
"""
|
||||
|
||||
current_text = active_profile or self.currentText()
|
||||
@@ -42,22 +39,9 @@ class ProfileComboBox(QComboBox):
|
||||
quick_profiles = self._quick_provider()
|
||||
quick_set = set(quick_profiles)
|
||||
|
||||
items: list[str] = []
|
||||
if show_empty_profile:
|
||||
items.append("")
|
||||
|
||||
items = list(quick_profiles)
|
||||
if active_profile and active_profile not in quick_set:
|
||||
items.append(active_profile)
|
||||
|
||||
for profile in quick_profiles:
|
||||
if profile not in items:
|
||||
items.append(profile)
|
||||
|
||||
if active_profile and active_profile not in quick_set:
|
||||
# keep active profile at the top when not in quick list
|
||||
items.remove(active_profile)
|
||||
insert_pos = 1 if show_empty_profile else 0
|
||||
items.insert(insert_pos, active_profile)
|
||||
items.insert(0, active_profile)
|
||||
|
||||
for profile in items:
|
||||
self.addItem(profile)
|
||||
@@ -68,15 +52,6 @@ class ProfileComboBox(QComboBox):
|
||||
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
|
||||
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
|
||||
|
||||
if profile == "":
|
||||
self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole)
|
||||
if active_profile is None:
|
||||
font = QFont(self.font())
|
||||
font.setItalic(True)
|
||||
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
|
||||
self.setCurrentIndex(idx)
|
||||
continue
|
||||
|
||||
if active_profile and profile == active_profile:
|
||||
tooltip = "Active workspace profile"
|
||||
if profile not in quick_set:
|
||||
@@ -94,23 +69,16 @@ class ProfileComboBox(QComboBox):
|
||||
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
|
||||
|
||||
# Restore selection if possible
|
||||
if show_empty_profile and active_profile is None:
|
||||
empty_idx = self.findText("")
|
||||
if empty_idx >= 0:
|
||||
self.setCurrentIndex(empty_idx)
|
||||
else:
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
self.blockSignals(False)
|
||||
if active_profile and self.currentText() != active_profile:
|
||||
idx = self.findText(active_profile)
|
||||
if idx >= 0:
|
||||
self.setCurrentIndex(idx)
|
||||
if show_empty_profile and self.currentText() == "":
|
||||
self.setToolTip("Unsaved empty workspace")
|
||||
elif active_profile and active_profile not in quick_set:
|
||||
if active_profile and active_profile not in quick_set:
|
||||
self.setToolTip("Active profile is not in quick select")
|
||||
else:
|
||||
self.setToolTip("")
|
||||
@@ -118,7 +86,7 @@ class ProfileComboBox(QComboBox):
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for BECDockArea.
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
@@ -171,7 +139,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in BECDockArea.
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
@@ -179,8 +147,8 @@ class WorkspaceConnection(BundleConnection):
|
||||
self.bundle_name = "workspace"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "workspace_is_locked"):
|
||||
raise AttributeError("Target widget must implement 'workspace_is_locked'.")
|
||||
if not hasattr(self.target_widget, "lock_workspace"):
|
||||
raise AttributeError("Target widget must implement 'lock_workspace'.")
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
@@ -188,7 +156,7 @@ class WorkspaceConnection(BundleConnection):
|
||||
# Connect the action to the target widget's method
|
||||
save_action = self.components.get_action("save_workspace").action
|
||||
if save_action.isVisible():
|
||||
save_action.triggered.connect(self.target_widget.save_profile_dialog)
|
||||
save_action.triggered.connect(self.target_widget.save_profile)
|
||||
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
|
||||
self.target_widget.load_profile
|
||||
@@ -208,7 +176,7 @@ class WorkspaceConnection(BundleConnection):
|
||||
# Disconnect the action from the target widget's method
|
||||
save_action = self.components.get_action("save_workspace").action
|
||||
if save_action.isVisible():
|
||||
save_action.triggered.disconnect(self.target_widget.save_profile_dialog)
|
||||
save_action.triggered.disconnect(self.target_widget.save_profile)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
@@ -7,12 +7,12 @@ from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanStatusMessage
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
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
|
||||
@@ -24,7 +24,7 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class AutoUpdates(BECMainWindow):
|
||||
_default_dock: CDockWidget | None
|
||||
_default_dock: BECDock
|
||||
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
@@ -37,16 +37,11 @@ class AutoUpdates(BECMainWindow):
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.dock_area = BECDockArea(
|
||||
parent=self,
|
||||
object_name="dock_area",
|
||||
enable_profile_management=False,
|
||||
startup_profile="skip",
|
||||
)
|
||||
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
|
||||
self.setCentralWidget(self.dock_area)
|
||||
self._auto_update_selected_device: str | None = None
|
||||
|
||||
self._default_dock = None # type: ignore
|
||||
self._default_dock = None # type:ignore
|
||||
self.current_widget: BECWidget | None = None
|
||||
self.dock_name = None
|
||||
self._enabled = True
|
||||
@@ -63,7 +58,7 @@ class AutoUpdates(BECMainWindow):
|
||||
Disconnect all connections for the auto updates.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._on_scan_status, MessageEndpoints.scan_status() # type: ignore
|
||||
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -111,11 +106,9 @@ class AutoUpdates(BECMainWindow):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
self.dock_area.delete_all()
|
||||
self.dock_name = "update_dock"
|
||||
self.current_widget = self.dock_area.new("Waveform")
|
||||
docks = self.dock_area.dock_list()
|
||||
self._default_dock = docks[0] if docks else None
|
||||
self._default_dock = self.dock_area.new(self.dock_name)
|
||||
self.current_widget = self._default_dock.new("Waveform")
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
|
||||
@@ -145,18 +138,16 @@ class AutoUpdates(BECMainWindow):
|
||||
Returns:
|
||||
BECWidget: The widget that was set.
|
||||
"""
|
||||
if self.current_widget is None:
|
||||
if self._default_dock is None or self.current_widget is None:
|
||||
logger.warning(
|
||||
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
|
||||
)
|
||||
self.start_default_dock()
|
||||
assert self.current_widget is not None
|
||||
|
||||
if self.current_widget.__class__.__name__ != widget:
|
||||
self.dock_area.delete_all()
|
||||
self.current_widget = self.dock_area.new(widget)
|
||||
docks = self.dock_area.dock_list()
|
||||
self._default_dock = docks[0] if docks else None
|
||||
if not self.current_widget.__class__.__name__ == widget:
|
||||
self._default_dock.delete(self.current_widget.object_name)
|
||||
self.current_widget = self._default_dock.new(widget)
|
||||
return self.current_widget
|
||||
|
||||
def get_selected_device(
|
||||
@@ -244,10 +235,10 @@ class AutoUpdates(BECMainWindow):
|
||||
wf = self.set_dock_to_widget("Waveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x = info.scan_report_devices[0] # type: ignore
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
|
||||
# For the y axis, get the selected device
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
@@ -256,8 +247,8 @@ class AutoUpdates(BECMainWindow):
|
||||
# as the label and title
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
@@ -265,7 +256,7 @@ class AutoUpdates(BECMainWindow):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_line_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
||||
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
|
||||
@@ -279,8 +270,8 @@ class AutoUpdates(BECMainWindow):
|
||||
scatter = self.set_dock_to_widget("ScatterWaveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type: ignore
|
||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
|
||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
|
||||
if None in (dev_x, dev_y, dev_z):
|
||||
return
|
||||
@@ -288,14 +279,11 @@ class AutoUpdates(BECMainWindow):
|
||||
# Clear the scatter waveform widget and plot the data
|
||||
scatter.clear_all()
|
||||
scatter.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
device_z=dev_z,
|
||||
label=f"Scan {info.scan_number} - {dev_z}",
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_grid_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}, device_z={dev_z}"
|
||||
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
|
||||
)
|
||||
|
||||
def best_effort(self, info: ScanStatusMessage) -> None:
|
||||
@@ -309,8 +297,8 @@ class AutoUpdates(BECMainWindow):
|
||||
# If the scan report devices are empty, there is nothing we can do
|
||||
if not info.scan_report_devices:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0] # type: ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
@@ -320,17 +308,15 @@ class AutoUpdates(BECMainWindow):
|
||||
# Clear the waveform widget and plot the data
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [best_effort]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
||||
)
|
||||
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
|
||||
|
||||
#######################################################################
|
||||
################# GUI Callbacks #######################################
|
||||
|
||||
2
bec_widgets/widgets/containers/dock/__init__.py
Normal file
2
bec_widgets/widgets/containers/dock/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .dock import BECDock
|
||||
from .dock_area import BECDockArea
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['dock_area.py']}
|
||||
@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECShell' name='bec_shell'>
|
||||
<widget class='BECDockArea' name='bec_dock_area'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
@@ -23,20 +23,20 @@ class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = BECShell(parent)
|
||||
t = BECDockArea(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
return "BEC Containers"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECShell.ICON_NAME)
|
||||
return designer_material_icon(BECDockArea.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_shell"
|
||||
return "bec_dock_area"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -48,7 +48,7 @@ class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECShell"
|
||||
return "BECDockArea"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
440
bec_widgets/widgets/containers/dock/dock.py
Normal file
440
bec_widgets/widgets/containers/dock/dock.py
Normal file
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock, DockLabel
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
|
||||
"bottom", description="The position of the dock."
|
||||
)
|
||||
parent_dock_area: Optional[str] | None = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
|
||||
class CustomDockLabel(DockLabel):
|
||||
def __init__(self, text: str, closable: bool = True):
|
||||
super().__init__(text, closable)
|
||||
if closable:
|
||||
red_icon = QtGui.QIcon()
|
||||
pixmap = QtGui.QPixmap(32, 32)
|
||||
pixmap.fill(QtCore.Qt.GlobalColor.red)
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
pen = QtGui.QPen(QtCore.Qt.GlobalColor.white)
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(8, 8, 24, 24)
|
||||
painter.drawLine(24, 8, 8, 24)
|
||||
painter.end()
|
||||
red_icon.addPixmap(pixmap)
|
||||
|
||||
self.closeButton.setIcon(red_icon)
|
||||
|
||||
def updateStyle(self):
|
||||
r = "3px"
|
||||
if self.dim:
|
||||
fg = "#aaa"
|
||||
bg = "#44a"
|
||||
border = "#339"
|
||||
else:
|
||||
fg = "#fff"
|
||||
bg = "#3f4042"
|
||||
border = "#3f4042"
|
||||
|
||||
if self.orientation == "vertical":
|
||||
self.vStyle = """DockLabel {
|
||||
background-color : %s;
|
||||
color : %s;
|
||||
border-top-right-radius: 0px;
|
||||
border-top-left-radius: %s;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-bottom-left-radius: %s;
|
||||
border-width: 0px;
|
||||
border-right: 2px solid %s;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-size: %s;
|
||||
}""" % (
|
||||
bg,
|
||||
fg,
|
||||
r,
|
||||
r,
|
||||
border,
|
||||
self.fontSize,
|
||||
)
|
||||
self.setStyleSheet(self.vStyle)
|
||||
else:
|
||||
self.hStyle = """DockLabel {
|
||||
background-color : %s;
|
||||
color : %s;
|
||||
border-top-right-radius: %s;
|
||||
border-top-left-radius: %s;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-width: 0px;
|
||||
border-bottom: 2px solid %s;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
font-size: %s;
|
||||
}""" % (
|
||||
bg,
|
||||
fg,
|
||||
r,
|
||||
r,
|
||||
border,
|
||||
self.fontSize,
|
||||
)
|
||||
self.setStyleSheet(self.hStyle)
|
||||
|
||||
|
||||
class BECDock(BECWidget, Dock):
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"element_list",
|
||||
"elements",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"show_title_bar",
|
||||
"set_title",
|
||||
"hide_title_bar",
|
||||
"available_widgets",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
object_name: str | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
closable: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
if config is None:
|
||||
config = DockConfig(
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
label = CustomDockLabel(text=name, closable=closable)
|
||||
super().__init__(
|
||||
parent=parent_dock_area,
|
||||
name=name,
|
||||
object_name=object_name,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
config=config,
|
||||
label=label,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.parent_dock_area = parent_dock_area
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
def dropEvent(self, event):
|
||||
source = event.source()
|
||||
old_area = source.area
|
||||
self.setOrientation("horizontal", force=True)
|
||||
super().dropEvent(event)
|
||||
if old_area in self.orig_area.tempAreas and old_area != self.orig_area:
|
||||
self.orig_area.removeTempArea(old_area)
|
||||
old_area.window().deleteLater()
|
||||
|
||||
def float(self):
|
||||
"""
|
||||
Float the dock.
|
||||
Overwrites the default pyqtgraph dock float.
|
||||
"""
|
||||
|
||||
# need to check if the dock is temporary and if it is the only dock in the area
|
||||
# fixes bug in pyqtgraph detaching
|
||||
if self.area.temporary == True and len(self.area.docks) <= 1:
|
||||
return
|
||||
elif self.area.temporary == True and len(self.area.docks) > 1:
|
||||
self.area.docks.pop(self.name(), None)
|
||||
super().float()
|
||||
else:
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def elements(self) -> dict[str, BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(dict): The widgets in the dock.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
return dict((widget.object_name, widget) for widget in self.element_list)
|
||||
|
||||
@property
|
||||
def element_list(self) -> list[BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(list): The widgets in the dock.
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
"""
|
||||
# self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
|
||||
self.label.hide()
|
||||
self.labelHidden = True
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Show the dock.
|
||||
"""
|
||||
super().show()
|
||||
self.show_title_bar()
|
||||
|
||||
def hide(self):
|
||||
"""
|
||||
Hide the dock.
|
||||
"""
|
||||
self.hide_title_bar()
|
||||
super().hide()
|
||||
|
||||
def show_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
"""
|
||||
# self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
|
||||
self.label.show()
|
||||
self.labelHidden = False
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title of the dock.
|
||||
|
||||
Args:
|
||||
title(str): The title of the dock.
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
Get the positions of the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget}
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def available_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
List all widgets that can be added to the dock.
|
||||
|
||||
Returns:
|
||||
list: The list of eligible widgets.
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
|
||||
if (docks := self.parent_dock_area.panel_list) is None:
|
||||
return []
|
||||
widgets = []
|
||||
for dock in docks:
|
||||
widgets.extend(dock.elements.keys())
|
||||
return widgets
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
name: str | None = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Add a widget to the dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
|
||||
name(str): The name of the widget.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to.
|
||||
rowspan(int): The number of rows the widget should span.
|
||||
colspan(int): The number of columns the widget should span.
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
# Check that Widget is not BECDock or BECDockArea
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
if widget_class_name in IGNORE_WIDGETS:
|
||||
raise ValueError(f"Widget {widget} can not be added to dock.")
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(
|
||||
widget_type=widget, object_name=name, parent_dock=self, parent=self
|
||||
),
|
||||
)
|
||||
else:
|
||||
widget.object_name = name
|
||||
|
||||
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
if hasattr(widget, "config"):
|
||||
widget.config.gui_id = widget.gui_id
|
||||
self.config.widgets[widget.object_name] = widget.config
|
||||
return widget
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to move.
|
||||
new_row(int): The new row to move the widget to.
|
||||
new_col(int): The new column to move the widget to.
|
||||
"""
|
||||
self.layout_manager.move_widget(widget, new_row, new_col)
|
||||
|
||||
def attach(self):
|
||||
"""
|
||||
Attach the dock to the parent dock area.
|
||||
"""
|
||||
self.parent_dock_area.remove_temp_area(self.area)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the dock from the parent dock area.
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
self.parent_dock_area.delete(self.object_name)
|
||||
|
||||
def delete(self, widget_name: str) -> None:
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_name(str): Delete the widget with the given name.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
|
||||
if len(widgets) == 0:
|
||||
logger.warning(
|
||||
f"Widget with name {widget_name} not found in dock {self.name()}. "
|
||||
f"Checking if gui_id was passed as widget_name."
|
||||
)
|
||||
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_name)
|
||||
if widget is None:
|
||||
logger.warning(
|
||||
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
widget = widgets[0]
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget.object_name, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
def delete_all(self):
|
||||
"""
|
||||
Remove all widgets from the dock.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
self.delete(widget.object_name)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the dock, including all its widgets.
|
||||
"""
|
||||
# # FIXME Cleanup might be called twice
|
||||
try:
|
||||
logger.info(f"Cleaning up dock {self.name()}")
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
except Exception as e:
|
||||
logger.error(f"Error while closing dock label: {e}")
|
||||
|
||||
# Remove the dock from the parent dock area
|
||||
if self.parent_dock_area:
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
self.parent_dock_area.config.docks.pop(self.name(), None)
|
||||
self.delete_all()
|
||||
self.widgets.clear()
|
||||
super().cleanup()
|
||||
self.deleteLater()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
dock = BECDock(name="dock")
|
||||
dock.show()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
633
bec_widgets/widgets/containers/dock/dock_area.py
Normal file
633
bec_widgets/widgets/containers/dock/dock_area.py
Normal file
@@ -0,0 +1,633 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
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.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
docks_state: Optional[dict] = Field(
|
||||
None, description="The state of the docks in the dock area."
|
||||
)
|
||||
|
||||
|
||||
class BECDockArea(BECWidget, QWidget):
|
||||
"""
|
||||
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"panels",
|
||||
"panel_list",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"save_state",
|
||||
"screenshot",
|
||||
"restore_state",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
object_name: str = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
object_name=object_name,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
config=config,
|
||||
**kwargs,
|
||||
)
|
||||
self._parent = parent # TODO probably not needed
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(5)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._instructions_visible = True
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dock_area = DockArea(parent=self)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self._setup_toolbar()
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.dock_area)
|
||||
|
||||
self._hook_toolbar()
|
||||
self.toolbar.show_bundles(
|
||||
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
|
||||
)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
|
||||
# Add plot menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_plots",
|
||||
ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"heatmap": MaterialIconAction(
|
||||
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
|
||||
bundle.add_action("menu_plots")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add control menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_devices",
|
||||
ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME,
|
||||
tooltip="Add Scan Control",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME,
|
||||
tooltip="Add Device Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
|
||||
bundle.add_action("menu_devices")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add utils menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_utils",
|
||||
ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME,
|
||||
tooltip="Add Scan Queue",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME,
|
||||
tooltip="Add VS Code",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
|
||||
bundle.add_action("menu_utils")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
########## Dock Actions ##########
|
||||
spacer = QWidget(parent=self)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
|
||||
bundle.add_action("spacer")
|
||||
bundle.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"save_state",
|
||||
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"restore_state",
|
||||
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"screenshot",
|
||||
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bundle.add_action("attach_all")
|
||||
bundle.add_action("save_state")
|
||||
bundle.add_action("restore_state")
|
||||
bundle.add_action("screenshot")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
menu_plots = self.toolbar.components.get_action("menu_plots")
|
||||
menu_devices = self.toolbar.components.get_action("menu_devices")
|
||||
menu_utils = self.toolbar.components.get_action("menu_utils")
|
||||
|
||||
menu_plots.actions["waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
|
||||
menu_plots.actions["scatter_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
)
|
||||
menu_plots.actions["multi_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
)
|
||||
menu_plots.actions["image"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
)
|
||||
menu_plots.actions["motor_map"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
)
|
||||
menu_plots.actions["heatmap"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
menu_devices.actions["scan_control"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
menu_devices.actions["positioner_box"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
menu_utils.actions["queue"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
menu_utils.actions["status"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
menu_utils.actions["vs_code"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
menu_utils.actions["progress_bar"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
menu_utils.actions["log_panel"].action.setEnabled(False)
|
||||
|
||||
menu_utils.actions["sbb_monitor"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
|
||||
)
|
||||
|
||||
# Icons
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
|
||||
self.toolbar.components.get_action("restore_state").action.triggered.connect(
|
||||
self.restore_state
|
||||
)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
# Run with RPC broadcast to namespace of all widgets
|
||||
with RPCRegister.delayed_broadcast():
|
||||
name = pascal_to_snake(widget_name)
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
|
||||
self.new(name=dock_name, widget=widget_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
|
||||
super().paintEvent(event)
|
||||
if self._instructions_visible:
|
||||
painter = QPainter(self)
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignCenter,
|
||||
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
Returns:
|
||||
dock_dict(dict): The docks in the dock area.
|
||||
"""
|
||||
return dict(self.dock_area.docks)
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict[str, BECDock]):
|
||||
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
|
||||
|
||||
@property
|
||||
def panel_list(self) -> list[BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The docks in the dock area.
|
||||
"""
|
||||
return list(self.dock_area.docks.values())
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
"""
|
||||
Get the temporary areas in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The temporary areas in the dock area.
|
||||
"""
|
||||
return list(map(str, self.dock_area.tempAreas))
|
||||
|
||||
@temp_areas.setter
|
||||
def temp_areas(self, value: list):
|
||||
self.dock_area.tempAreas = list(map(str, value))
|
||||
|
||||
@SafeSlot()
|
||||
def restore_state(
|
||||
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
|
||||
):
|
||||
"""
|
||||
Restore the state of the dock area. If no state is provided, the last state is restored.
|
||||
|
||||
Args:
|
||||
state(dict): The state to restore.
|
||||
missing(Literal['ignore','error']): What to do if a dock is missing.
|
||||
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
|
||||
"""
|
||||
if state is None:
|
||||
state = self.config.docks_state
|
||||
if state is None:
|
||||
return
|
||||
self.dock_area.restoreState(state, missing=missing, extra=extra)
|
||||
|
||||
@SafeSlot()
|
||||
def save_state(self) -> dict:
|
||||
"""
|
||||
Save the state of the dock area.
|
||||
|
||||
Returns:
|
||||
dict: The state of the dock area.
|
||||
"""
|
||||
last_state = self.dock_area.saveState()
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
widget: str | QWidget | None = None,
|
||||
widget_name: str | None = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = True,
|
||||
floating: bool = False,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
) -> BECDock:
|
||||
"""
|
||||
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to be displayed and for further references. Has to be unique.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
|
||||
relative_to(BECDock): The dock to which the new dock should be added relative to.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floating(bool): Whether the dock is detached after creating.
|
||||
row(int): The row of the added widget.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
colspan(int): The colspan of the added widget.
|
||||
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
dock_names = [
|
||||
dock.object_name for dock in self.panel_list
|
||||
] # pylint: disable=protected-access
|
||||
if name is not None: # Name is provided
|
||||
if name in dock_names:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self.object_name} and id {self.gui_id}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
|
||||
dock = BECDock(
|
||||
parent=self,
|
||||
name=name, # this is dock name pyqtgraph property, this is displayed on label
|
||||
object_name=name, # this is a real qt object name passed to BECConnector
|
||||
parent_dock_area=self,
|
||||
closable=closable,
|
||||
)
|
||||
dock.config.position = position
|
||||
self.config.docks[dock.name()] = dock.config
|
||||
# The dock.name is equal to the name passed to BECDock
|
||||
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
dock.hide_title_bar()
|
||||
elif len(self.dock_area.docks) > 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.show_title_bar()
|
||||
|
||||
if widget is not None:
|
||||
# Check if widget name exists.
|
||||
dock.new(
|
||||
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
|
||||
)
|
||||
if (
|
||||
self._instructions_visible
|
||||
): # TODO still decide how initial instructions should be handled
|
||||
self._instructions_visible = False
|
||||
self.update()
|
||||
if floating:
|
||||
dock.detach()
|
||||
return dock
|
||||
|
||||
def detach_dock(self, dock_name: str) -> BECDock:
|
||||
"""
|
||||
Undock a dock from the dock area.
|
||||
|
||||
Args:
|
||||
dock_name(str): The dock to undock.
|
||||
|
||||
Returns:
|
||||
BECDock: The undocked dock.
|
||||
"""
|
||||
dock = self.dock_area.docks[dock_name]
|
||||
dock.detach()
|
||||
return dock
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area.
|
||||
"""
|
||||
while self.dock_area.tempAreas:
|
||||
for temp_area in self.dock_area.tempAreas:
|
||||
self.remove_temp_area(temp_area)
|
||||
|
||||
def remove_temp_area(self, area):
|
||||
"""
|
||||
Remove a temporary area from the dock area.
|
||||
This is a patched method of pyqtgraph's removeTempArea
|
||||
"""
|
||||
if area not in self.dock_area.tempAreas:
|
||||
# FIXME add some context for the logging, I am not sure which object is passed.
|
||||
# It looks like a pyqtgraph.DockArea
|
||||
logger.info(f"Attempted to remove dock_area, but was not floating.")
|
||||
return
|
||||
self.dock_area.tempAreas.remove(area)
|
||||
area.window().close()
|
||||
area.window().deleteLater()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def show(self):
|
||||
"""Show all windows including floating docks."""
|
||||
super().show()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().show()
|
||||
|
||||
def hide(self):
|
||||
"""Hide all windows including floating docks."""
|
||||
super().hide()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().hide()
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""
|
||||
Delete all docks.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock_name in self.panels.keys():
|
||||
self.delete(dock_name)
|
||||
|
||||
def delete(self, dock_name: str):
|
||||
"""
|
||||
Delete a dock by name.
|
||||
|
||||
Args:
|
||||
dock_name(str): The name of the dock to delete.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(dock_name, None)
|
||||
self.config.docks.pop(dock_name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
else:
|
||||
raise ValueError(f"Dock with name {dock_name} does not exist.")
|
||||
# self._broadcast_update()
|
||||
|
||||
def remove(self) -> None:
|
||||
"""
|
||||
Remove the dock area. If the dock area is embedded in a BECMainWindow and
|
||||
is set as the central widget, the main window will be closed.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if isinstance(parent, BECMainWindow):
|
||||
central_widget = parent.centralWidget()
|
||||
if central_widget is self:
|
||||
# Closing the parent will also close the dock area
|
||||
parent.close()
|
||||
return
|
||||
|
||||
self.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||
dock_1.new(widget="DarkModeButton")
|
||||
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
dock_area.new(widget="DarkModeButton")
|
||||
dock_area.show()
|
||||
dock_area.setGeometry(100, 100, 800, 600)
|
||||
app.topLevelWidgets()
|
||||
WidgetHierarchy.print_becconnector_hierarchy_from_app()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin
|
||||
from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -101,12 +101,14 @@ class Explorer(BECWidget, QWidget):
|
||||
palette = get_theme_palette()
|
||||
separator_color = palette.mid().color()
|
||||
|
||||
self.splitter.setStyleSheet(f"""
|
||||
self.splitter.setStyleSheet(
|
||||
f"""
|
||||
QSplitter::handle {{
|
||||
height: 0.1px;
|
||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _update_spacer(self) -> None:
|
||||
"""Update the spacer size based on section states"""
|
||||
|
||||
@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView(parent=self)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel(parent=self)
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel(parent=self)
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
@@ -134,13 +134,15 @@ class NotificationToast(QFrame):
|
||||
bg.setAlphaF(0.30)
|
||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||
icon_btn.setFixedSize(40, 40)
|
||||
icon_btn.setStyleSheet(f"""
|
||||
icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px; /* perfect circle */
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
title_lbl = QtWidgets.QLabel(self._title)
|
||||
|
||||
@@ -325,13 +327,15 @@ class NotificationToast(QFrame):
|
||||
bg = QtGui.QColor(SEVERITY[value.value]["color"])
|
||||
bg.setAlphaF(0.30)
|
||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(f"""
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.apply_theme(self._theme)
|
||||
# keep injected gradient in sync
|
||||
if getattr(self, "_hg_enabled", False):
|
||||
@@ -387,7 +391,8 @@ class NotificationToast(QFrame):
|
||||
card_bg.setAlphaF(0.88)
|
||||
btn_hover = self._accent_color.name()
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
#NotificationToast {{
|
||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||
border-radius: 12px;
|
||||
@@ -401,15 +406,18 @@ class NotificationToast(QFrame):
|
||||
font-size: 14px;
|
||||
}}
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
self.trace_view.setStyleSheet(f"""
|
||||
self.trace_view.setStyleSheet(
|
||||
f"""
|
||||
background:{trace_bg};
|
||||
color:{palette['body']};
|
||||
border:none;
|
||||
border-radius:8px;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||
@@ -430,13 +438,15 @@ class NotificationToast(QFrame):
|
||||
else:
|
||||
badge_bg.setAlphaF(0.30)
|
||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(f"""
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
@@ -583,7 +593,8 @@ class NotificationCentre(QScrollArea):
|
||||
self.setWidgetResizable(True)
|
||||
# transparent background so only the toast cards are visible
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#NotificationCentre { background: transparent; }
|
||||
#NotificationCentre QScrollBar:vertical {
|
||||
background: transparent;
|
||||
@@ -599,7 +610,8 @@ class NotificationCentre(QScrollArea):
|
||||
#NotificationCentre QScrollBar::sub-line:vertical { height: 0; }
|
||||
#NotificationCentre QScrollBar::add-page:vertical,
|
||||
#NotificationCentre QScrollBar::sub-page:vertical { background: transparent; }
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(fixed_width)
|
||||
@@ -946,7 +958,8 @@ class NotificationIndicator(QWidget):
|
||||
self._group.buttonToggled.connect(self._button_toggled)
|
||||
|
||||
# minimalistic look: no frames or backgrounds on the buttons
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -957,7 +970,8 @@ class NotificationIndicator(QWidget):
|
||||
background: rgba(255, 255, 255, 40);
|
||||
font-weight: 600;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# initial state: none checked (auto‑dismiss behaviour)
|
||||
for k in kinds:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
@@ -31,17 +31,12 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||
from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import (
|
||||
WidgetHierarchyDialog,
|
||||
)
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
@@ -54,16 +49,13 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.status_bar = self.statusBar()
|
||||
self._launcher_window = None
|
||||
self.setWindowTitle(window_title)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
|
||||
# Notification Centre overlay
|
||||
self.notification_centre = NotificationCentre(parent=self) # Notification layer
|
||||
self.notification_broker = BECNotificationBroker(parent=self)
|
||||
self.notification_broker = BECNotificationBroker()
|
||||
self._nc_margin = 16
|
||||
self._position_notification_centre()
|
||||
self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None
|
||||
|
||||
# Init ui
|
||||
self._init_ui()
|
||||
@@ -196,18 +188,14 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
def _add_scan_progress_bar(self):
|
||||
|
||||
# Setting HoverWidget for the scan progress bar - minimal and full version
|
||||
self._scan_progress_bar_simple = ScanProgressBar(
|
||||
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
|
||||
self._scan_progress_bar_simple.show_elapsed_time = False
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
self._scan_progress_bar_simple.show_source_label = False
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_full = ScanProgressBar(
|
||||
self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
)
|
||||
@@ -265,7 +253,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def fetch_theme(self) -> str:
|
||||
def _fetch_theme(self) -> str:
|
||||
return self.app.theme.theme
|
||||
|
||||
def _get_launcher_from_qapp(self):
|
||||
@@ -286,16 +274,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
Show the launcher if it exists.
|
||||
"""
|
||||
launcher = self._get_launcher_from_qapp()
|
||||
if launcher is None:
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
|
||||
cli_server = getattr(self.bec_dispatcher, "cli_server", None)
|
||||
if cli_server is None:
|
||||
logger.warning("Cannot open launcher: CLI server is not available.")
|
||||
return
|
||||
launcher = LaunchWindow(gui_id=f"{cli_server.gui_id}:launcher")
|
||||
launcher.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore[arg-type]
|
||||
self._launcher_window = launcher
|
||||
if launcher:
|
||||
launcher.show()
|
||||
launcher.activateWindow()
|
||||
@@ -333,11 +311,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
|
||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
theme_menu.addSeparator()
|
||||
widget_tree_action = QAction("Show Widget Hierarchy", self)
|
||||
widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog)
|
||||
theme_menu.addAction(widget_tree_action)
|
||||
|
||||
# Set the default theme
|
||||
if hasattr(self.app, "theme") and self.app.theme:
|
||||
theme_name = self.app.theme.theme.lower()
|
||||
@@ -421,23 +394,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
def _show_widget_hierarchy_dialog(self):
|
||||
if self._widget_hierarchy_dialog is None:
|
||||
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
|
||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
dialog.destroyed.connect(lambda: setattr(self, "_widget_hierarchy_dialog", None))
|
||||
self._widget_hierarchy_dialog = dialog
|
||||
self._widget_hierarchy_dialog.refresh()
|
||||
self._widget_hierarchy_dialog.show()
|
||||
self._widget_hierarchy_dialog.raise_()
|
||||
self._widget_hierarchy_dialog.activateWindow()
|
||||
|
||||
def cleanup(self):
|
||||
# Widget hierarchy dialog cleanup
|
||||
if self._widget_hierarchy_dialog is not None:
|
||||
self._widget_hierarchy_dialog.close()
|
||||
self._widget_hierarchy_dialog = None
|
||||
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
|
||||
@@ -123,7 +123,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _on_device_readback(
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
@@ -66,13 +66,6 @@ class PositionerBox(PositionerBoxBase):
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||
ui_min_size = self.ui.minimumSize()
|
||||
ui_min_hint = self.ui.minimumSizeHint()
|
||||
self.setMinimumSize(
|
||||
max(ui_min_size.width(), ui_min_hint.width()),
|
||||
max(ui_min_size.height(), ui_min_hint.height()),
|
||||
)
|
||||
|
||||
# fix the size of the device box
|
||||
db = self.ui.device_box
|
||||
|
||||
@@ -32,7 +32,6 @@ class DeviceInputConfig(ConnectionConfig):
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
apply_filter: bool = True
|
||||
signal_class_filter: list[str] = []
|
||||
|
||||
@field_validator("device_filter")
|
||||
@classmethod
|
||||
@@ -126,13 +125,11 @@ class DeviceInputBase(BECWidget):
|
||||
current_device = WidgetIO.get_value(widget=self, as_string=True)
|
||||
self.config.device_filter = self.device_filter
|
||||
self.config.readout_filter = self.readout_filter
|
||||
self.config.signal_class_filter = self.signal_class_filter
|
||||
if self.apply_filter is False:
|
||||
return
|
||||
all_dev = self.dev.enabled_devices
|
||||
devs = self._filter_devices_by_signal_class(all_dev)
|
||||
# Filter based on device class
|
||||
devs = [dev for dev in devs if self._check_device_filter(dev)]
|
||||
devs = [dev for dev in all_dev if self._check_device_filter(dev)]
|
||||
# Filter based on readout priority
|
||||
devs = [dev for dev in devs if self._check_readout_filter(dev)]
|
||||
self.devices = [device.name for device in devs]
|
||||
@@ -193,27 +190,6 @@ class DeviceInputBase(BECWidget):
|
||||
self.config.apply_filter = value
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def signal_class_filter(self) -> list[str]:
|
||||
"""
|
||||
Get the signal class filter for devices.
|
||||
|
||||
Returns:
|
||||
list[str]: List of signal class names used for filtering devices.
|
||||
"""
|
||||
return self.config.signal_class_filter
|
||||
|
||||
@signal_class_filter.setter
|
||||
def signal_class_filter(self, value: list[str] | None):
|
||||
"""
|
||||
Set the signal class filter and update the device list.
|
||||
|
||||
Args:
|
||||
value (list[str] | None): List of signal class names to filter by.
|
||||
"""
|
||||
self.config.signal_class_filter = value or []
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_device(self):
|
||||
"""Include devices in filters."""
|
||||
@@ -403,20 +379,6 @@ class DeviceInputBase(BECWidget):
|
||||
"""
|
||||
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
|
||||
|
||||
def _filter_devices_by_signal_class(
|
||||
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
|
||||
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
|
||||
"""Filter devices by signal class, if a signal class filter is set."""
|
||||
if not self.config.signal_class_filter:
|
||||
return devices
|
||||
if not self.client or not hasattr(self.client, "device_manager"):
|
||||
return []
|
||||
signals = FilterIO.update_with_signal_class(
|
||||
widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client
|
||||
)
|
||||
allowed_devices = {device_name for device_name, _, _ in signals}
|
||||
return [dev for dev in devices if dev.name in allowed_devices]
|
||||
|
||||
def _check_readout_filter(
|
||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||
) -> bool:
|
||||
|
||||
@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
@@ -17,8 +17,6 @@ class DeviceSignalInputBaseConfig(ConnectionConfig):
|
||||
"""Configuration class for DeviceSignalInputBase."""
|
||||
|
||||
signal_filter: str | list[str] | None = None
|
||||
signal_class_filter: list[str] | None = None
|
||||
ndim_filter: int | list[int] | None = None
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
device: str | None = None
|
||||
@@ -266,7 +264,6 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
raise_on_false(bool): Raise ValueError if device is not found.
|
||||
"""
|
||||
if device in self.dev:
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
@@ -26,12 +27,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_device", "devices"]
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_reset = Signal()
|
||||
@@ -50,7 +51,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
signal_class_filter: list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -63,7 +63,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self._is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
self._set_first_element_as_empty = False
|
||||
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
@@ -86,10 +85,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
# Device filter default is None
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
|
||||
if signal_class_filter is not None:
|
||||
self.signal_class_filter = signal_class_filter
|
||||
|
||||
# Set default device if passed
|
||||
if default is not None:
|
||||
self.set_device(default)
|
||||
@@ -186,70 +181,21 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
device = self.itemData(idx)[0] # type: ignore[assignment]
|
||||
return super().validate_device(device)
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text represents a valid device selection."""
|
||||
return self._is_valid_input
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setWindowTitle("DeviceComboBox demo")
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
layout.addWidget(QLabel("Device filter controls"))
|
||||
controls = QHBoxLayout()
|
||||
layout.addLayout(controls)
|
||||
|
||||
class_input = QLineEdit()
|
||||
class_input.setPlaceholderText("signal_class_filter (comma-separated), e.g. AsyncSignal")
|
||||
controls.addWidget(class_input)
|
||||
|
||||
filter_device = QCheckBox("Device")
|
||||
filter_positioner = QCheckBox("Positioner")
|
||||
filter_signal = QCheckBox("Signal")
|
||||
filter_computed = QCheckBox("ComputedSignal")
|
||||
controls.addWidget(filter_device)
|
||||
controls.addWidget(filter_positioner)
|
||||
controls.addWidget(filter_signal)
|
||||
controls.addWidget(filter_computed)
|
||||
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
combo = DeviceComboBox()
|
||||
combo.set_first_element_as_empty = True
|
||||
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
|
||||
layout.addWidget(combo)
|
||||
|
||||
def _apply_filters():
|
||||
raw = class_input.text().strip()
|
||||
if raw:
|
||||
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
|
||||
else:
|
||||
combo.signal_class_filter = []
|
||||
combo.filter_to_device = filter_device.isChecked()
|
||||
combo.filter_to_positioner = filter_positioner.isChecked()
|
||||
combo.filter_to_signal = filter_signal.isChecked()
|
||||
combo.filter_to_computed_signal = filter_computed.isChecked()
|
||||
|
||||
class_input.textChanged.connect(_apply_filters)
|
||||
filter_device.toggled.connect(_apply_filters)
|
||||
filter_positioner.toggled.connect(_apply_filters)
|
||||
filter_signal.toggled.connect(_apply_filters)
|
||||
filter_computed.toggled.connect(_apply_filters)
|
||||
_apply_filters()
|
||||
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
@@ -31,11 +31,12 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_config_update = Signal()
|
||||
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
ICON_NAME = "edit_note"
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QSize, Qt, Signal
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
@@ -21,27 +22,18 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device: Device name to filter signals from.
|
||||
signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details.
|
||||
signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown.
|
||||
ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
store_signal_config: Whether to store the full signal config in the combobox item data.
|
||||
require_device: If True, signals are only shown/validated when a device is set.
|
||||
Signals:
|
||||
device_signal_changed: Emitted when the current text represents a valid signal selection.
|
||||
signal_reset: Emitted when validation fails and the selection should be treated as cleared.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
|
||||
USER_ACCESS = ["set_signal", "set_device", "signals"]
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
RPC = True
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
signal_reset = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,13 +42,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
config: DeviceSignalInputBaseConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: list[Kind] | None = None,
|
||||
signal_class_filter: list[str] | None = None,
|
||||
ndim_filter: int | list[int] | None = None,
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
store_signal_config: bool = True,
|
||||
require_device: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -69,64 +57,26 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._set_first_element_as_empty = True
|
||||
self._signal_class_filter = signal_class_filter or []
|
||||
self._store_signal_config = store_signal_config
|
||||
self.config.ndim_filter = ndim_filter or None
|
||||
self._require_device = require_device
|
||||
self._is_valid_input = False
|
||||
|
||||
# Note: Runtime arguments (e.g. device, default, arg_name) intentionally take
|
||||
# precedence over values from the passed-in config. Full reconciliation and
|
||||
# restoration of state between designer-provided config and runtime arguments
|
||||
# is not yet implemented, as earlier attempts caused issues with QtDesigner.
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
self.currentTextChanged.connect(self.on_text_changed)
|
||||
|
||||
# Kind filtering is always applied; class filtering is additive. If signal_filter is None,
|
||||
# we default to hinted+normal, even when signal_class_filter is empty or None. To disable
|
||||
# kinds, pass an explicit signal_filter or toggle include_* after init.
|
||||
if signal_filter is not None:
|
||||
self.set_filter(signal_filter)
|
||||
else:
|
||||
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
|
||||
|
||||
if device is not None:
|
||||
self.set_device(device)
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. When signal_class_filter is active, ensures base-class
|
||||
logic runs and then refreshes the signal list to show only signals from
|
||||
that device matching the signal class filter.
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
"""
|
||||
super().set_device(device)
|
||||
|
||||
if self._signal_class_filter:
|
||||
# Refresh the signal list to show only this device's signals
|
||||
self.update_signals_from_signal_classes()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict, dict)
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Update the filters for the combobox.
|
||||
When signal_class_filter is active, skip the normal Kind-based filtering.
|
||||
|
||||
Args:
|
||||
content (dict | None): Content dictionary from BEC event.
|
||||
metadata (dict | None): Metadata dictionary from BEC event.
|
||||
"""
|
||||
"""Update the filters for the combobox"""
|
||||
super().update_signals_from_filters(content, metadata)
|
||||
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
return
|
||||
# pylint: disable=protected-access
|
||||
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
|
||||
if len(self._config_signals) > 0:
|
||||
@@ -168,63 +118,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def signal_class_filter(self) -> list[str]:
|
||||
"""
|
||||
Get the list of signal classes to filter.
|
||||
|
||||
Returns:
|
||||
list[str]: List of signal class names to filter.
|
||||
"""
|
||||
return self._signal_class_filter
|
||||
|
||||
@signal_class_filter.setter
|
||||
def signal_class_filter(self, value: list[str] | None):
|
||||
"""
|
||||
Set the signal class filter.
|
||||
|
||||
Args:
|
||||
value (list[str] | None): List of signal class names to filter, or None/empty
|
||||
to disable class-based filtering and revert to the default behavior.
|
||||
"""
|
||||
normalized_value = value or []
|
||||
self._signal_class_filter = normalized_value
|
||||
self.config.signal_class_filter = normalized_value
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
else:
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeProperty(int)
|
||||
def ndim_filter(self) -> int:
|
||||
"""Dimensionality filter for signals."""
|
||||
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
|
||||
|
||||
@ndim_filter.setter
|
||||
def ndim_filter(self, value: int):
|
||||
self.config.ndim_filter = None if value < 0 else value
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def require_device(self) -> bool:
|
||||
"""
|
||||
If True, signals are only shown/validated when a device is set.
|
||||
|
||||
Note:
|
||||
This property affects list rebuilding only when a signal_class_filter
|
||||
is active. Without a signal class filter, the available signals are
|
||||
managed by the standard Kind-based filtering.
|
||||
"""
|
||||
return self._require_device
|
||||
|
||||
@require_device.setter
|
||||
def require_device(self, value: bool):
|
||||
self._require_device = value
|
||||
# Rebuild list when toggled, but only when using signal_class_filter
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""
|
||||
Set the combobox to the object name of the signal.
|
||||
@@ -255,109 +148,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_signal_name(self) -> str:
|
||||
"""
|
||||
Get the signal name from the combobox.
|
||||
|
||||
Returns:
|
||||
str: The signal name.
|
||||
"""
|
||||
signal_name = self.currentText()
|
||||
index = self.findText(signal_name)
|
||||
if index == -1:
|
||||
return signal_name
|
||||
|
||||
signal_info = self.itemData(index)
|
||||
if signal_info:
|
||||
signal_name = signal_info.get("obj_name", signal_name)
|
||||
|
||||
return signal_name if signal_name else ""
|
||||
|
||||
def get_signal_config(self) -> dict | None:
|
||||
"""
|
||||
Get the signal config from the combobox for the currently selected signal.
|
||||
|
||||
Returns:
|
||||
dict | None: The signal configuration dictionary or None if not available.
|
||||
"""
|
||||
if not self._store_signal_config:
|
||||
return None
|
||||
|
||||
index = self.currentIndex()
|
||||
if index == -1:
|
||||
return None
|
||||
|
||||
signal_info = self.itemData(index)
|
||||
return signal_info if signal_info else None
|
||||
|
||||
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
|
||||
"""
|
||||
Update the combobox with signals filtered by signal classes and optionally by ndim.
|
||||
Uses device_manager.get_bec_signals() to retrieve signals.
|
||||
If a device is set, only shows signals from that device.
|
||||
|
||||
Args:
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
Can be a single int or a list of ints. Use None to include all dimensions.
|
||||
If not provided, uses the previously set ndim_filter.
|
||||
"""
|
||||
if not self._signal_class_filter:
|
||||
return
|
||||
|
||||
if self._require_device and not self._device:
|
||||
self.clear()
|
||||
self._signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
|
||||
# Update stored ndim_filter if a new one is provided
|
||||
if ndim_filter is not None:
|
||||
self.config.ndim_filter = ndim_filter
|
||||
|
||||
self.clear()
|
||||
|
||||
# Get signals with ndim filtering applied at the FilterIO level
|
||||
signals = FilterIO.update_with_signal_class(
|
||||
widget=self,
|
||||
signal_class_filter=self._signal_class_filter,
|
||||
client=self.client,
|
||||
ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO
|
||||
)
|
||||
|
||||
# Track signals for validation and FilterIO selection
|
||||
self._signals = []
|
||||
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
# Filter by device if one is set
|
||||
if self._device and device_name != self._device:
|
||||
continue
|
||||
if self._signal_filter:
|
||||
kind_str = signal_config.get("kind_str")
|
||||
if kind_str is not None and kind_str not in {
|
||||
kind.name for kind in self._signal_filter
|
||||
}:
|
||||
continue
|
||||
|
||||
# Get storage_name for tooltip
|
||||
storage_name = signal_config.get("storage_name", "")
|
||||
|
||||
# Store the full signal config as item data if requested
|
||||
if self._store_signal_config:
|
||||
self.addItem(signal_name, signal_config)
|
||||
else:
|
||||
self.addItem(signal_name)
|
||||
|
||||
# Track for validation
|
||||
self._signals.append(signal_name)
|
||||
|
||||
# Set tooltip to storage_name (Qt.ToolTipRole = 3)
|
||||
if storage_name:
|
||||
self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole)
|
||||
|
||||
# Keep FilterIO selection in sync for validate_signal
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_selection(self):
|
||||
"""Reset the selection of the combobox."""
|
||||
@@ -368,44 +158,22 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Validate and emit only when the signal is valid.
|
||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
When using signal_class_filter, device validation is skipped.
|
||||
|
||||
Args:
|
||||
text (str): Text in the combobox.
|
||||
"""
|
||||
self.check_validity(text)
|
||||
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""Check if the current value is a valid signal and emit only when valid."""
|
||||
if self._signal_class_filter:
|
||||
if self._require_device and (not self._device or not input_text):
|
||||
is_valid = False
|
||||
else:
|
||||
is_valid = self.validate_signal(input_text)
|
||||
else:
|
||||
if self._require_device and not self.validate_device(self._device):
|
||||
is_valid = False
|
||||
else:
|
||||
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
|
||||
|
||||
if is_valid:
|
||||
self._is_valid_input = True
|
||||
self.device_signal_changed.emit(input_text)
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.signal_reset.emit()
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
if self.validate_device(self.device) is False:
|
||||
return
|
||||
if self.validate_signal(text) is False:
|
||||
return
|
||||
self.device_signal_changed.emit(text)
|
||||
|
||||
@property
|
||||
def selected_signal_comp_name(self) -> str:
|
||||
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text represents a valid signal selection."""
|
||||
return self._is_valid_input
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
@@ -419,14 +187,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
box = SignalComboBox(
|
||||
device="waveform",
|
||||
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
|
||||
ndim_filter=[1, 2],
|
||||
store_signal_config=True,
|
||||
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
|
||||
) # change signal filter class to test
|
||||
box.setEditable(True)
|
||||
box = SignalComboBox(device="samx")
|
||||
layout.addWidget(box)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
@@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
device_signal_changed = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
RPC = True
|
||||
ICON_NAME = "vital_signs"
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from typing import Any, Callable, Generator, Iterable, TypeVar
|
||||
|
||||
from bec_lib.utils.json_extended import ExtendedEncoder
|
||||
from bec_lib.utils.json import ExtendedEncoder
|
||||
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
|
||||
from qtpy.QtWidgets import QListWidgetItem
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class _DeviceEntryWidget(QFrame):
|
||||
self.setToolTip(self._rich_text())
|
||||
|
||||
def _rich_text(self):
|
||||
return dedent(f"""
|
||||
return dedent(
|
||||
f"""
|
||||
<b><u><h2> {self._device_spec.name}: </h2></u></b>
|
||||
<table>
|
||||
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
|
||||
@@ -59,7 +60,8 @@ class _DeviceEntryWidget(QFrame):
|
||||
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
|
||||
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
|
||||
</table>
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def setup_title_layout(self, device_spec: HashableDevice):
|
||||
self._title_layout = QHBoxLayout()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Type
|
||||
from typing import Type
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -191,7 +191,7 @@ class DeviceConfigTemplate(QtWidgets.QWidget):
|
||||
if widget is not None:
|
||||
self._set_value_for_widget(widget, value)
|
||||
|
||||
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: Any) -> None:
|
||||
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None:
|
||||
"""
|
||||
Set the value for a widget based on its type.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Module for custom input widgets used in device configuration templates."""
|
||||
|
||||
from ast import literal_eval
|
||||
from typing import Any, Callable
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -15,7 +15,7 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _try_literal_eval(value: str) -> Any:
|
||||
def _try_literal_eval(value: any) -> any:
|
||||
"""Consolidated function for literal evaluation of a value."""
|
||||
if value in ["true", "True"]:
|
||||
return True
|
||||
@@ -407,7 +407,7 @@ class DeviceConfigField(BaseModel):
|
||||
static: bool = False
|
||||
placeholder_text: str | None = None
|
||||
validation_callback: list[Callable[[str], bool]] | None = None
|
||||
default: Any = None
|
||||
default: any = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||
from typing import Any, Callable, Iterable, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
@@ -28,9 +26,6 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
|
||||
get_validation_icons,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import ConfigAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_DeviceCfgIter = Iterable[dict[str, Any]]
|
||||
@@ -204,8 +199,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Signal emitted if devices are added (updated) or removed
|
||||
# - device_configs: List of device configurations.
|
||||
# - added: True if devices were added/updated, False if removed.
|
||||
# - skip validation: True if validation should be skipped for added/updated devices.
|
||||
device_configs_changed = QtCore.Signal(list, bool, bool)
|
||||
device_configs_changed = QtCore.Signal(list, bool)
|
||||
# Signal emitted when device selection changes, emits list of selected device configs
|
||||
selected_devices = QtCore.Signal(list)
|
||||
# Signal emitted when a device row is double-clicked, emits the device config
|
||||
@@ -213,15 +207,10 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Signal emitted when the device config is in sync with Redis
|
||||
device_config_in_sync_with_redis = QtCore.Signal(bool)
|
||||
|
||||
# Request multiple validation updates for devices
|
||||
request_update_multiple_device_validations = QtCore.Signal(list)
|
||||
# Request update after client DEVICE_UPDATE event
|
||||
request_update_after_client_device_update = QtCore.Signal()
|
||||
|
||||
_auto_size_request = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None):
|
||||
super().__init__(parent=parent)
|
||||
self.headers_key_map: dict[str, str] = {
|
||||
"Valid": "valid",
|
||||
"Connect": "connect",
|
||||
@@ -277,66 +266,15 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Connect slots
|
||||
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
||||
self.table.cellDoubleClicked.connect(self._on_cell_double_clicked)
|
||||
self.request_update_multiple_device_validations.connect(
|
||||
self.update_multiple_device_validations
|
||||
)
|
||||
self.request_update_after_client_device_update.connect(self._on_device_config_update)
|
||||
# Install event filter
|
||||
self.table.installEventFilter(self)
|
||||
|
||||
# Add hook to BECClient for DeviceUpdates
|
||||
self.client_callback_id = self.client.callbacks.register(
|
||||
event_type=EventType.DEVICE_UPDATE, callback=self.__on_client_device_update_event
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources."""
|
||||
self.row_data.clear() # Drop references to row data..
|
||||
self.client.callbacks.remove(self.client_callback_id) # Unregister callback
|
||||
# self._autosize_timer.stop()
|
||||
super().cleanup()
|
||||
|
||||
def __on_client_device_update_event(
|
||||
self, action: "ConfigAction", config: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
"""Handle DEVICE_UPDATE events from the BECClient."""
|
||||
self.request_update_after_client_device_update.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_device_config_update(self) -> None:
|
||||
"""Handle device configuration updates from the BECClient."""
|
||||
# Determine the overlapping device configs between Redis and the table
|
||||
device_config_overlap_with_bec = self._get_overlapping_configs()
|
||||
if len(device_config_overlap_with_bec) > 0:
|
||||
# Notify any listeners about the update, the device manager devices will now be up to date
|
||||
self.device_configs_changed.emit(device_config_overlap_with_bec, True, True)
|
||||
|
||||
# Correct all connection statuses in the table which are ConnectionStatus.CONNECTED
|
||||
# to ConnectionStatus.CAN_CONNECT
|
||||
device_status_updates = []
|
||||
validation_results = self.get_validation_results()
|
||||
for device_name, (cfg, config_status, connection_status) in validation_results.items():
|
||||
if device_name is None:
|
||||
continue
|
||||
# Check if config is not in the overlap, but connection status is CONNECTED
|
||||
# Update to CAN_CONNECT
|
||||
if cfg not in device_config_overlap_with_bec:
|
||||
if connection_status == ConnectionStatus.CONNECTED.value:
|
||||
device_status_updates.append(
|
||||
(cfg, config_status, ConnectionStatus.CAN_CONNECT.value, "")
|
||||
)
|
||||
# Update only if there are any updates
|
||||
if len(device_status_updates) > 0:
|
||||
# NOTE We need to emit here a signal to call update_multiple_device_validations
|
||||
# as this otherwise can cause problems with being executed from a python callback
|
||||
# thread which are not properly scheduled in the Qt event loop. We see that this
|
||||
# has caused issues in form of segfaults under certain usage of the UI. Please
|
||||
# do not remove this signal & slot mechanism!
|
||||
self.request_update_multiple_device_validations.emit(device_status_updates)
|
||||
|
||||
# Check if in sync with BEC server session
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Custom hooks for table events
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -830,51 +768,6 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
logger.error(f"Error comparing device configs: {e}")
|
||||
return False
|
||||
|
||||
def _get_overlapping_configs(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the device configs that overlap between the table and the config in the current running BEC session.
|
||||
A device will be ignored if it is disabled in the BEC session.
|
||||
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to check.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: The list of overlapping device configs.
|
||||
"""
|
||||
overlapping_configs = []
|
||||
for cfg in self.get_device_config():
|
||||
device_name = cfg.get("name", None)
|
||||
if device_name is None:
|
||||
continue
|
||||
if self._is_device_in_redis_session(device_name, cfg):
|
||||
overlapping_configs.append(cfg)
|
||||
|
||||
return overlapping_configs
|
||||
|
||||
def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool:
|
||||
"""Check if a device is in the running section."""
|
||||
dev_obj = self.client.device_manager.devices.get(device_name, None)
|
||||
if dev_obj is None or dev_obj.enabled is False:
|
||||
return False
|
||||
return self._compare_device_configs(dev_obj._config, device_config)
|
||||
|
||||
def _compare_device_configs(self, config1: dict, config2: dict) -> bool:
|
||||
"""Compare two device configurations through the Device model in bec_lib.atlas_models.
|
||||
|
||||
Args:
|
||||
config1 (dict): The first device configuration.
|
||||
config2 (dict): The second device configuration.
|
||||
|
||||
Returns:
|
||||
bool: True if the configurations are equivalent, False otherwise.
|
||||
"""
|
||||
try:
|
||||
model1 = DeviceModel.model_validate(config1)
|
||||
model2 = DeviceModel.model_validate(config2)
|
||||
return model1 == model2
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Public API to manage device configs in the table
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -929,53 +822,47 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Public API to be called via signals/slots
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@SafeSlot(list)
|
||||
def set_device_config(self, device_configs: _DeviceCfgIter):
|
||||
"""
|
||||
Set the device config. This will clear any existing configs.
|
||||
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to set.
|
||||
skip_validation (bool): Whether to skip validation for the set devices.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Loading device configurations...")
|
||||
with self.table_sort_on_hold:
|
||||
self.clear_device_configs()
|
||||
cfgs_added = []
|
||||
for cfg in device_configs:
|
||||
self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
|
||||
cfgs_added.append(cfg)
|
||||
self.device_configs_changed.emit(cfgs_added, True, skip_validation)
|
||||
self.device_configs_changed.emit(cfgs_added, True)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot()
|
||||
def clear_device_configs(self):
|
||||
"""Clear the device configs. Skips validation by default."""
|
||||
self.set_busy(True)
|
||||
"""Clear the device configs."""
|
||||
self.set_busy(True, text="Clearing device configurations...")
|
||||
device_configs = self.get_device_config()
|
||||
with self.table_sort_on_hold:
|
||||
self._clear_table()
|
||||
self.device_configs_changed.emit(
|
||||
device_configs, False, True
|
||||
) # Skip validation for removals
|
||||
self.device_configs_changed.emit(device_configs, False)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@SafeSlot(list)
|
||||
def add_device_configs(self, device_configs: _DeviceCfgIter):
|
||||
"""
|
||||
Add devices to the config. If a device already exists, it will be replaced. If the validation is
|
||||
skipped, the device will be added with UNKNOWN state to the table and has to be manually adjusted
|
||||
by the user later on.
|
||||
Add devices to the config. If a device already exists, it will be replaced.
|
||||
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to add.
|
||||
skip_validation (bool): Whether to skip validation for the added devices.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Adding device configurations...")
|
||||
already_in_table = []
|
||||
not_in_table = []
|
||||
with self.table_sort_on_hold:
|
||||
@@ -988,30 +875,27 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Remove existing rows first
|
||||
if len(already_in_table) > 0:
|
||||
self._remove_rows_by_name([cfg["name"] for cfg in already_in_table])
|
||||
self.device_configs_changed.emit(
|
||||
already_in_table, False, True
|
||||
) # Skip validation for removals
|
||||
self.device_configs_changed.emit(already_in_table, False)
|
||||
|
||||
all_configs = already_in_table + not_in_table
|
||||
if len(all_configs) > 0:
|
||||
for cfg in already_in_table + not_in_table:
|
||||
self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
|
||||
|
||||
self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation)
|
||||
self.device_configs_changed.emit(already_in_table + not_in_table, True)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@SafeSlot(list)
|
||||
def update_device_configs(self, device_configs: _DeviceCfgIter):
|
||||
"""
|
||||
Update devices in the config. If a device does not exist, it will be added.
|
||||
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to update.
|
||||
skip_validation (bool): Whether to skip validation for the updated devices.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Loading device configurations...")
|
||||
cfgs_updated = []
|
||||
with self.table_sort_on_hold:
|
||||
for cfg in device_configs:
|
||||
@@ -1023,10 +907,10 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
row = self._update_row(cfg)
|
||||
if row is not None:
|
||||
cfgs_updated.append(cfg)
|
||||
self.device_configs_changed.emit(cfgs_updated, True, skip_validation)
|
||||
self.device_configs_changed.emit(cfgs_updated, True)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(list)
|
||||
def remove_device_configs(self, device_configs: _DeviceCfgIter):
|
||||
@@ -1036,16 +920,14 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (dict[str, dict]): The device configs to remove.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Removing device configurations...")
|
||||
cfgs_to_be_removed = list(device_configs)
|
||||
with self.table_sort_on_hold:
|
||||
self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed])
|
||||
self.device_configs_changed.emit(
|
||||
cfgs_to_be_removed, False, True
|
||||
) # Skip validation for removals
|
||||
self.device_configs_changed.emit(cfgs_to_be_removed, False) #
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_device(self, device_name: str):
|
||||
@@ -1055,19 +937,19 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_name (str): The name of the device to remove.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text=f"Removing device configuration for {device_name}...")
|
||||
row_data = self.row_data.get(device_name)
|
||||
if not row_data:
|
||||
logger.warning(f"Device {device_name} not found in table for removal.")
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
return
|
||||
with self.table_sort_on_hold:
|
||||
self._remove_rows_by_name([row_data.data["name"]])
|
||||
cfgs = [{"name": device_name, **row_data.data}]
|
||||
self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals
|
||||
self.device_configs_changed.emit(cfgs, False)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(list)
|
||||
def update_multiple_device_validations(self, validation_results: _ValidationResultIter):
|
||||
@@ -1079,24 +961,16 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to update.
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Updating device validations in session...")
|
||||
self.table.setSortingEnabled(False)
|
||||
logger.info(
|
||||
f"Updating multiple device validation statuses with names {[cfg.get('name', '') for cfg, _, _, _ in validation_results]}..."
|
||||
)
|
||||
for cfg, config_status, connection_status, _ in validation_results:
|
||||
logger.info(
|
||||
f"Updating device {cfg.get('name', '')} with config status {config_status} and connection status {connection_status}..."
|
||||
)
|
||||
row = self._find_row_by_name(cfg.get("name", ""))
|
||||
if row is None:
|
||||
logger.warning(f"Device {cfg.get('name')} not found in table for session update.")
|
||||
continue
|
||||
self._update_device_row_status(row, config_status, connection_status)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def update_device_validation(
|
||||
@@ -1109,13 +983,13 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
|
||||
"""
|
||||
self.set_busy(True)
|
||||
self.set_busy(True, text="Updating device validation status...")
|
||||
row = self._find_row_by_name(device_config.get("name", ""))
|
||||
if row is None:
|
||||
logger.warning(
|
||||
f"Device {device_config.get('name')} not found in table for validation update."
|
||||
)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
return
|
||||
# Disable here sorting without context manager to avoid triggering of registered
|
||||
# resizing methods. Those can be quite heavy, thus, should not run on every
|
||||
@@ -1125,4 +999,4 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.table.setSortingEnabled(True)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False)
|
||||
self.set_busy(False, text="")
|
||||
|
||||
@@ -69,12 +69,11 @@ class DeviceTest(QtCore.QRunnable):
|
||||
enable_connect: bool,
|
||||
force_connect: bool,
|
||||
timeout: float,
|
||||
device_manager_ds: object | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.uuid = device_model.uuid
|
||||
test_config = {device_model.device_name: device_model.device_config}
|
||||
self.tester = StaticDeviceTest(config_dict=test_config, device_manager_ds=device_manager_ds)
|
||||
self.tester = StaticDeviceTest(config_dict=test_config)
|
||||
self.signals = DeviceTestResult()
|
||||
self.device_config = device_model.device_config
|
||||
self.enable_connect = enable_connect
|
||||
@@ -265,6 +264,7 @@ class LegendLabel(QtWidgets.QWidget):
|
||||
icon = self._icons["config_status"][status]
|
||||
icon_widget = ValidationButton(parent=self, icon=icon)
|
||||
icon_widget.setEnabled(False)
|
||||
icon_widget.set_enabled_style(False)
|
||||
icon_widget.setToolTip(f"Device Configuration: {status.description()}")
|
||||
layout.addWidget(icon_widget, 0, ii + 1)
|
||||
|
||||
@@ -282,6 +282,7 @@ class LegendLabel(QtWidgets.QWidget):
|
||||
icon = self._icons["connection_status"][status]
|
||||
icon_widget = ValidationButton(parent=self, icon=icon)
|
||||
icon_widget.setEnabled(False)
|
||||
icon_widget.set_enabled_style(False)
|
||||
icon_widget.setToolTip(f"Connection Status: {status.description()}")
|
||||
layout.addWidget(icon_widget, 1, ii + 1)
|
||||
layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column
|
||||
@@ -311,7 +312,6 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
def __init__(self, parent=None, client=None, hide_legend: bool = False):
|
||||
super().__init__(parent=parent, client=client, theme_update=True)
|
||||
self._running_ophyd_tests = False
|
||||
self._keep_visible_after_validation: list[str] = []
|
||||
if not READY_TO_TEST:
|
||||
self.setDisabled(True)
|
||||
self.thread_pool_manager = None
|
||||
@@ -339,25 +339,6 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
self._main_layout.addWidget(legend_widget)
|
||||
self._thread_pool_poll_loop()
|
||||
|
||||
def add_device_to_keep_visible_after_validation(self, device_name: str) -> None:
|
||||
"""Add a device name to the list of devices to keep visible after validation.
|
||||
|
||||
Args:
|
||||
device_name (str): Name of the device to keep visible.
|
||||
"""
|
||||
if device_name not in self._keep_visible_after_validation:
|
||||
self._keep_visible_after_validation.append(device_name)
|
||||
|
||||
def remove_device_to_keep_visible_after_validation(self, device_name: str) -> None:
|
||||
"""Remove a device name from the list of devices to keep visible after validation.
|
||||
|
||||
Args:
|
||||
device_name (str): Name of the device to remove.
|
||||
"""
|
||||
if device_name in self._keep_visible_after_validation:
|
||||
self._keep_visible_after_validation.remove(device_name)
|
||||
self._remove_device(device_name)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Apply the current theme to the widget."""
|
||||
self._colors = get_accent_colors()
|
||||
@@ -489,26 +470,9 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
widgets: list[ValidationListItem] = self.list_widget.get_widgets()
|
||||
return [widget.device_model.device_config for widget in widgets]
|
||||
|
||||
@SafeSlot(list, bool, bool)
|
||||
def device_table_config_changed(
|
||||
self, device_configs: list[dict[str, Any]], added: bool, skip_validation: bool
|
||||
) -> None:
|
||||
"""
|
||||
Slot to handle device config changes in the device table.
|
||||
|
||||
Args:
|
||||
device_configs (list[dict[str, Any]]): List of device configurations.
|
||||
added (bool): Whether the devices are added to the existing list.
|
||||
skip_validation (bool): Whether to skip validation for the added devices.
|
||||
"""
|
||||
self.change_device_configs(
|
||||
device_configs=device_configs, added=added, skip_validation=skip_validation
|
||||
)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
@SafeSlot(list, bool, bool)
|
||||
@SafeSlot(list, bool, bool, bool, float)
|
||||
@SafeSlot(list, bool, bool, bool, float, bool)
|
||||
def change_device_configs(
|
||||
self,
|
||||
device_configs: list[dict[str, Any]],
|
||||
@@ -516,24 +480,17 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
connect: bool = False,
|
||||
force_connect: bool = False,
|
||||
timeout: float = 5.0,
|
||||
skip_validation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Change the device configuration to test. If added is False, existing devices are removed.
|
||||
Device tests will be removed based on device names. No duplicates are allowed.
|
||||
|
||||
For validation runs, results are emitted via the validation_completed signal. Unless devices
|
||||
are already in the running session with the same config, in which case the combined results
|
||||
of all such devices are emitted via the multiple_validations_completed signal. NOTE Please make
|
||||
sure to connect to both signals if you want to capture all results.
|
||||
|
||||
Args:
|
||||
device_configs (list[dict[str, Any]]): List of device configurations.
|
||||
added (bool): Whether the devices are added to the existing list.
|
||||
connect (bool, optional): Whether to attempt connection during validation. Defaults to False.
|
||||
force_connect (bool, optional): Whether to force connection during validation. Defaults to False.
|
||||
timeout (float, optional): Timeout for connection attempt. Defaults to 5.0.
|
||||
skip_validation (bool, optional): Whether to skip validation for the added devices. Defaults to False.
|
||||
"""
|
||||
if not READY_TO_TEST:
|
||||
logger.error("Cannot change device configs: dependencies not available.")
|
||||
@@ -547,10 +504,9 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
if device_name is None: # Config missing name, will be skipped..
|
||||
logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.")
|
||||
continue
|
||||
if not added: # Remove requested, holds priority over skip_validation
|
||||
if not added: # Remove requested
|
||||
self._remove_device_config(cfg)
|
||||
continue
|
||||
# Check if device is already in running session with the same config
|
||||
if self._is_device_in_redis_session(cfg.get("name"), cfg):
|
||||
logger.debug(
|
||||
f"Device {device_name} already in running session with same config. Skipping."
|
||||
@@ -563,52 +519,21 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
"Device already in session.",
|
||||
)
|
||||
)
|
||||
# If in addition, the device is to be kept visible after validation, we ensure it is added
|
||||
# and potentially update it's config & validation icons
|
||||
if device_name in self._keep_visible_after_validation:
|
||||
if not self._device_already_exists(device_name):
|
||||
self._add_device_config(
|
||||
cfg,
|
||||
connect=connect,
|
||||
force_connect=force_connect,
|
||||
timeout=timeout,
|
||||
skip_validation=True,
|
||||
)
|
||||
# Now make sure that the existing widget is updated to reflect the CONNECTED & VALID status
|
||||
widget: ValidationListItem = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
self._on_device_test_completed(
|
||||
cfg,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
else: # If not to be kept visible, we ensure it is removed from the list
|
||||
self._remove_device_config(cfg)
|
||||
continue # Now we continue to the next device config
|
||||
if skip_validation is True: # Skip validation requested, so we skip this
|
||||
self._remove_device_config(cfg)
|
||||
continue
|
||||
# New device case, that is not in BEC session
|
||||
if not self._device_already_exists(cfg.get("name")):
|
||||
if not self._device_already_exists(cfg.get("name")): # New device case
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
else: # Update existing, but removing first
|
||||
logger.info(f"Device {cfg.get('name')} already exists, re-adding it.")
|
||||
self._remove_device_config(cfg, force_remove=True)
|
||||
self._remove_device_config(cfg)
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
# Send out batch of updates for devices already in session
|
||||
if devices_already_in_session:
|
||||
# NOTE: Use singleShot here to ensure that the signal is emitted after all other scheduled
|
||||
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
|
||||
# this is relevant for the DeviceFormDialog which opens a modal dialog during validation
|
||||
# and therefore must not have the signal emitted immediately in the same event loop iteration.
|
||||
# Otherwise, the dialog would block signal processing.
|
||||
QtCore.QTimer.singleShot(
|
||||
0, lambda: self.multiple_validations_completed.emit(devices_already_in_session)
|
||||
)
|
||||
self.multiple_validations_completed.emit(devices_already_in_session)
|
||||
|
||||
def cancel_validation(self, device_name: str) -> None:
|
||||
"""Cancel a running validation for a specific device.
|
||||
@@ -648,12 +573,7 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
return device_name in self.list_widget
|
||||
|
||||
def _add_device_config(
|
||||
self,
|
||||
device_config: dict[str, Any],
|
||||
connect: bool,
|
||||
force_connect: bool,
|
||||
timeout: float,
|
||||
skip_validation: bool = False,
|
||||
self, device_config: dict[str, Any], connect: bool, force_connect: bool, timeout: float
|
||||
) -> None:
|
||||
device_name = device_config.get("name")
|
||||
# Check if device is in redis session with same config, if yes don't even bother testing..
|
||||
@@ -668,30 +588,22 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
)
|
||||
widget.request_rerun_validation.connect(self._on_request_rerun_validation)
|
||||
self.list_widget.add_widget_item(device_name, widget)
|
||||
if not skip_validation:
|
||||
self.__delayed_submit_test(widget, connect, force_connect, timeout)
|
||||
self.__delayed_submit_test(widget, connect, force_connect, timeout)
|
||||
|
||||
def _remove_device(self, device_name: str, force_remove: bool = False) -> None:
|
||||
def _remove_device_config(self, device_config: dict[str, Any]) -> None:
|
||||
device_name = device_config.get("name")
|
||||
if not device_name:
|
||||
logger.error(f"Device config missing 'name': {device_config}. Cannot remove device.")
|
||||
return
|
||||
if not self._device_already_exists(device_name):
|
||||
logger.debug(
|
||||
f"Device with name {device_name} not found in OphydValidation, can't remove it."
|
||||
)
|
||||
return
|
||||
if device_name in self._keep_visible_after_validation and not force_remove:
|
||||
logger.debug(
|
||||
f"Device with name {device_name} is set to be kept visible after validation, not removing it."
|
||||
)
|
||||
return
|
||||
if self.thread_pool_manager:
|
||||
self.thread_pool_manager.clear_device_in_queue(device_name)
|
||||
self.list_widget.remove_widget_item(device_name)
|
||||
|
||||
def _remove_device_config(
|
||||
self, device_config: dict[str, Any], force_remove: bool = False
|
||||
) -> None:
|
||||
device_name = device_config.get("name")
|
||||
self._remove_device(device_name, force_remove=force_remove)
|
||||
|
||||
@SafeSlot(str, dict, bool, bool, float)
|
||||
def _on_request_rerun_validation(
|
||||
self,
|
||||
@@ -753,15 +665,11 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
# Remove widget from list as it's safe to assume it can be loaded.
|
||||
self._remove_device_config(widget.device_model.device_config)
|
||||
return
|
||||
dm_ds = None
|
||||
if self.client:
|
||||
dm_ds = getattr(self.client, "device_manager", None)
|
||||
runnable = DeviceTest(
|
||||
device_model=widget.device_model,
|
||||
enable_connect=connect,
|
||||
force_connect=force_connect,
|
||||
timeout=timeout,
|
||||
device_manager_ds=dm_ds,
|
||||
)
|
||||
widget.validation_scheduled()
|
||||
if self.thread_pool_manager:
|
||||
@@ -781,6 +689,16 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
if not self._device_already_exists(device_name):
|
||||
logger.debug(f"Received test result for unknown device {device_name}. Ignoring.")
|
||||
return
|
||||
if config_status == ConfigStatus.VALID.value and connection_status in [
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
ConnectionStatus.CAN_CONNECT.value,
|
||||
]:
|
||||
# Validated successfully, remove item from running list
|
||||
self.list_widget.remove_widget_item(device_name)
|
||||
self.validation_completed.emit(
|
||||
device_config, config_status, connection_status, error_message
|
||||
)
|
||||
return
|
||||
widget = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
widget.on_validation_finished(
|
||||
@@ -788,12 +706,6 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
config_status=config_status,
|
||||
connection_status=connection_status,
|
||||
)
|
||||
if config_status == ConfigStatus.VALID.value and connection_status in [
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
ConnectionStatus.CAN_CONNECT.value,
|
||||
]:
|
||||
# Validated successfully, remove item from running list
|
||||
self._remove_device(device_name)
|
||||
self.validation_completed.emit(
|
||||
device_config, config_status, connection_status, error_message
|
||||
)
|
||||
|
||||
@@ -31,14 +31,27 @@ class ValidationButton(QtWidgets.QPushButton):
|
||||
self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.transparent_style = "background-color: transparent; border: none;"
|
||||
if icon:
|
||||
self.setIcon(icon)
|
||||
self.setFlat(True)
|
||||
self.setEnabled(True)
|
||||
|
||||
def setEnabled(self, enabled: bool) -> None:
|
||||
self.set_enabled_style(enabled)
|
||||
return super().setEnabled(enabled)
|
||||
|
||||
def set_enabled_style(self, enabled: bool) -> None:
|
||||
"""Set the enabled state of the button with style update.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether the button should be enabled.
|
||||
"""
|
||||
if enabled:
|
||||
self.setStyleSheet("")
|
||||
else:
|
||||
self.setStyleSheet(self.transparent_style)
|
||||
|
||||
|
||||
class ValidationDialog(QtWidgets.QDialog):
|
||||
"""
|
||||
@@ -295,11 +308,13 @@ class ValidationListItem(QtWidgets.QWidget):
|
||||
# Enable/disable buttons based on status
|
||||
config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID]
|
||||
self.status_button.setEnabled(config_but_en)
|
||||
self.status_button.set_enabled_style(config_but_en)
|
||||
connect_but_en = connection_status in [
|
||||
ConnectionStatus.UNKNOWN,
|
||||
ConnectionStatus.CANNOT_CONNECT,
|
||||
]
|
||||
self.connection_button.setEnabled(connect_but_en)
|
||||
self.connection_button.set_enabled_style(connect_but_en)
|
||||
|
||||
@SafeSlot()
|
||||
def validation_scheduled(self):
|
||||
@@ -308,7 +323,9 @@ class ValidationListItem(QtWidgets.QWidget):
|
||||
"Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN
|
||||
)
|
||||
self.status_button.setEnabled(False)
|
||||
self.status_button.set_enabled_style(False)
|
||||
self.connection_button.setEnabled(False)
|
||||
self.connection_button.set_enabled_style(False)
|
||||
self._spinner.setVisible(True)
|
||||
|
||||
@SafeSlot()
|
||||
|
||||
@@ -9,7 +9,6 @@ from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
@@ -172,13 +171,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
# Wrap metadata form in a group box
|
||||
self._metadata_group = QGroupBox("Scan Metadata", self)
|
||||
self._metadata_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
metadata_layout = QVBoxLayout(self._metadata_group)
|
||||
metadata_layout.addWidget(self._metadata_form)
|
||||
|
||||
self.layout.addWidget(self._metadata_group)
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
||||
|
||||
@@ -275,10 +275,12 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
button.setEnabled(True)
|
||||
else:
|
||||
button.setEnabled(False)
|
||||
button.setStyleSheet(f"""
|
||||
button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
|
||||
QPushButton:disabled {{ background-color: grey;color: white; }}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.action_buttons[param_name] = button
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.action_buttons[param_name])
|
||||
|
||||
@@ -47,13 +47,15 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
||||
)
|
||||
|
||||
def _init_bec_kernel(self):
|
||||
self.execute("""
|
||||
self.execute(
|
||||
"""
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
bec = BECIPythonClient()
|
||||
bec.start()
|
||||
dev = bec.device_manager.devices if bec else None
|
||||
scans = bec.scans if bec else None
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _cleanup_bec(self):
|
||||
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:
|
||||
|
||||
@@ -6,10 +6,10 @@ from typing import Any, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.macro_update_handler import has_executable_code
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
|
||||
**kwargs,
|
||||
)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
||||
self.dock_manager.installEventFilter(self)
|
||||
self._last_focused_editor: CDockWidget | None = None
|
||||
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
||||
initial_editor = self.add_editor()
|
||||
if isinstance(initial_editor, CDockWidget):
|
||||
self.last_focused_editor = initial_editor
|
||||
self._install_manager_scan_and_fix_guards()
|
||||
|
||||
def _create_editor_widget(self) -> MonacoWidget:
|
||||
"""Create a configured Monaco editor widget."""
|
||||
@@ -73,8 +73,7 @@ class MonacoDock(DockAreaWidget):
|
||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||
self.save_enabled.emit(widget.modified)
|
||||
|
||||
@staticmethod
|
||||
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
|
||||
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||
"""Update the tab title to show modification status with a dot indicator."""
|
||||
current_title = dock.windowTitle()
|
||||
|
||||
@@ -99,12 +98,14 @@ class MonacoDock(DockAreaWidget):
|
||||
return
|
||||
|
||||
active_sig = signatures[signature.get("activeSignature", 0)]
|
||||
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
|
||||
|
||||
# Get signature label and documentation
|
||||
label = active_sig.get("label", "")
|
||||
doc_obj = active_sig.get("documentation", {})
|
||||
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
||||
|
||||
# Format the Markdown output
|
||||
# Format the markdown output
|
||||
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
||||
self.signature_help.emit(markdown)
|
||||
|
||||
@@ -140,12 +141,10 @@ class MonacoDock(DockAreaWidget):
|
||||
# Do not remove the last dock; just wipe its editor content
|
||||
# Temporarily disable read-only mode if the editor is read-only
|
||||
# so we can clear the content for reuse
|
||||
self.reset_widget(monaco_widget)
|
||||
monaco_widget.set_readonly(False)
|
||||
monaco_widget.set_text("")
|
||||
dock.setWindowTitle("Untitled")
|
||||
dock.setTabToolTip("Untitled")
|
||||
icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True)
|
||||
dock.setIcon(icon)
|
||||
self.last_focused_editor = dock
|
||||
return
|
||||
|
||||
# Otherwise, proceed to close and delete the dock
|
||||
@@ -155,19 +154,7 @@ class MonacoDock(DockAreaWidget):
|
||||
if self.last_focused_editor is dock:
|
||||
self.last_focused_editor = None
|
||||
# After topology changes, make sure single-tab areas get a plus button
|
||||
self._scan_and_fix_areas()
|
||||
|
||||
@staticmethod
|
||||
def reset_widget(widget: MonacoWidget):
|
||||
"""
|
||||
Reset the given Monaco editor widget to its initial state.
|
||||
|
||||
Args:
|
||||
widget (MonacoWidget): The Monaco editor widget to reset.
|
||||
"""
|
||||
widget.set_readonly(False)
|
||||
widget.set_text("", reset=True)
|
||||
widget.metadata["scope"] = ""
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
def _ensure_area_plus(self, area):
|
||||
if area is None:
|
||||
@@ -193,23 +180,23 @@ class MonacoDock(DockAreaWidget):
|
||||
# pylint: disable=protected-access
|
||||
area._monaco_plus_btn = plus_btn
|
||||
|
||||
def _install_manager_scan_and_fix_guards(self) -> None:
|
||||
"""
|
||||
Track ADS structural changes to trigger scan and fix of dock areas for plus button injection.
|
||||
"""
|
||||
self.dock_manager.dockAreaCreated.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.dockWidgetAdded.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.stateRestored.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.restoringState.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._scan_and_fix_areas)
|
||||
self._scan_and_fix_areas()
|
||||
|
||||
def _scan_and_fix_areas(self, *_arg):
|
||||
def _scan_and_fix_areas(self):
|
||||
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
||||
areas = self.dock_manager.findChildren(CDockAreaWidget)
|
||||
for a in areas:
|
||||
self._ensure_area_plus(a)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
# Track dock manager events
|
||||
if obj is self.dock_manager and event.type() in (
|
||||
QEvent.Type.ChildAdded,
|
||||
QEvent.Type.ChildRemoved,
|
||||
QEvent.Type.LayoutRequest,
|
||||
):
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def add_editor(
|
||||
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
||||
) -> CDockWidget:
|
||||
@@ -258,19 +245,14 @@ class MonacoDock(DockAreaWidget):
|
||||
if area_widget is not None:
|
||||
self._ensure_area_plus(area_widget)
|
||||
|
||||
self._scan_and_fix_areas()
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
self.last_focused_editor = dock
|
||||
return dock
|
||||
|
||||
def open_file(self, file_name: str, scope: str = "") -> None:
|
||||
def open_file(self, file_name: str, scope: str | None = None) -> None:
|
||||
"""
|
||||
Open a file in the specified area. If the file is already open, activate it.
|
||||
|
||||
Args:
|
||||
file_name (str): The path to the file to open.
|
||||
scope (str): The scope to set for the editor metadata.
|
||||
"""
|
||||
|
||||
open_files = self._get_open_files()
|
||||
if file_name in open_files:
|
||||
dock = self._get_editor_dock(file_name)
|
||||
@@ -299,7 +281,8 @@ class MonacoDock(DockAreaWidget):
|
||||
editor_dock.setWindowTitle(file)
|
||||
editor_dock.setTabToolTip(file_name)
|
||||
editor_widget.open_file(file_name)
|
||||
editor_widget.metadata["scope"] = scope
|
||||
if scope is not None:
|
||||
editor_widget.metadata["scope"] = scope
|
||||
self.last_focused_editor = editor_dock
|
||||
return
|
||||
|
||||
@@ -307,7 +290,8 @@ class MonacoDock(DockAreaWidget):
|
||||
editor_dock = self.add_editor(title=file, tooltip=file_name)
|
||||
widget = cast(MonacoWidget, editor_dock.widget())
|
||||
widget.open_file(file_name)
|
||||
widget.metadata["scope"] = scope
|
||||
if scope is not None:
|
||||
widget.metadata["scope"] = scope
|
||||
editor_dock.setAsCurrentTab()
|
||||
self.last_focused_editor = editor_dock
|
||||
|
||||
|
||||
@@ -110,12 +110,9 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
file_name (str): Set the file name
|
||||
reset (bool): If True, reset the original content to the new text.
|
||||
"""
|
||||
self._current_file = file_name if file_name else self._current_file
|
||||
if reset:
|
||||
self._current_file = file_name
|
||||
self._original_content = text
|
||||
else:
|
||||
self._current_file = file_name if file_name else self._current_file
|
||||
|
||||
self.editor.set_text(text, uri=file_name)
|
||||
|
||||
def get_text(self) -> str:
|
||||
@@ -362,7 +359,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget.set_language("python")
|
||||
widget.set_theme("vs-dark")
|
||||
widget.editor.set_minimap_enabled(False)
|
||||
widget.set_text("""
|
||||
widget.set_text(
|
||||
"""
|
||||
import numpy as np
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -379,7 +377,8 @@ if TYPE_CHECKING:
|
||||
# This is a comment
|
||||
def hello_world():
|
||||
print("Hello, world!")
|
||||
""")
|
||||
"""
|
||||
)
|
||||
widget.set_highlighted_lines(1, 3)
|
||||
widget.show()
|
||||
qapp.exec_()
|
||||
|
||||
@@ -53,8 +53,6 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self._additional_md_box)
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
|
||||
@@ -80,27 +78,12 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict"""
|
||||
form_data = self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
# If scan_name is empty, set it to the current scan
|
||||
if "scan_name" in form_data and not form_data["scan_name"]:
|
||||
form_data["scan_name"] = self._scan_name
|
||||
|
||||
return form_data
|
||||
return self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
def populate(self):
|
||||
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
||||
super().populate()
|
||||
|
||||
# Set scan_name field to current scan if it exists and is empty
|
||||
if "scan_name" not in self.widget_dict:
|
||||
return
|
||||
scan_name_widget = self.widget_dict["scan_name"]
|
||||
if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue():
|
||||
return
|
||||
if hasattr(scan_name_widget, "setValue"):
|
||||
scan_name_widget.setValue(self._scan_name)
|
||||
|
||||
def set_schema_from_scan(self, scan_name: str | None):
|
||||
self._scan_name = scan_name or ""
|
||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||
|
||||
@@ -6,11 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import (
|
||||
DeviceInitializationProgressBarPlugin,
|
||||
)
|
||||
from bec_widgets.widgets.editors.vscode.vs_code_editor_plugin import VSCodeEditorPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['vscode.py']}
|
||||
@@ -5,19 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DeviceInitializationProgressBar' name='device_initialization_progress_bar'>
|
||||
<widget class='VSCodeEditor' name='vs_code_editor'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
@@ -25,20 +23,20 @@ class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): #
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = DeviceInitializationProgressBar(parent)
|
||||
t = VSCodeEditor(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
return "BEC Developer"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME)
|
||||
return designer_material_icon(VSCodeEditor.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "device_initialization_progress_bar"
|
||||
return "vs_code_editor"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -50,10 +48,10 @@ class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): #
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DeviceInitializationProgressBar"
|
||||
return "VSCodeEditor"
|
||||
|
||||
def toolTip(self):
|
||||
return "A progress bar that displays the progress of device initialization."
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
203
bec_widgets/widgets/editors/vscode/vscode.py
Normal file
203
bec_widgets/widgets/editors/vscode/vscode.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import os
|
||||
import select
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
|
||||
from bec_widgets.widgets.editors.website.website import WebsiteWidget
|
||||
|
||||
|
||||
class VSCodeInstructionMessage(BaseModel):
|
||||
command: Literal["open", "write", "close", "zenMode", "save", "new", "setCursor"]
|
||||
content: str = ""
|
||||
|
||||
|
||||
def get_free_port():
|
||||
"""
|
||||
Get a free port on the local machine.
|
||||
|
||||
Returns:
|
||||
int: The free port number
|
||||
"""
|
||||
sock = socket.socket()
|
||||
sock.bind(("", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
class VSCodeEditor(WebsiteWidget):
|
||||
"""
|
||||
A widget to display the VSCode editor.
|
||||
"""
|
||||
|
||||
file_saved = Signal(str)
|
||||
|
||||
token = "bec"
|
||||
host = "127.0.0.1"
|
||||
|
||||
PLUGIN = True
|
||||
USER_ACCESS = []
|
||||
ICON_NAME = "developer_mode_tv"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
|
||||
self.process = None
|
||||
self.port = get_free_port()
|
||||
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
|
||||
self.start_server()
|
||||
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
|
||||
|
||||
def start_server(self):
|
||||
"""
|
||||
Start the server.
|
||||
|
||||
This method starts the server for the VSCode editor in a subprocess.
|
||||
"""
|
||||
|
||||
env = os.environ.copy()
|
||||
env["BEC_Widgets_GUIID"] = self.gui_id
|
||||
env["BEC_REDIS_HOST"] = self.client.connector.host
|
||||
cmd = shlex.split(
|
||||
f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms"
|
||||
)
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
env=env,
|
||||
)
|
||||
|
||||
os.set_blocking(self.process.stdout.fileno(), False)
|
||||
while self.process.poll() is None:
|
||||
readylist, _, _ = select.select([self.process.stdout], [], [], 1)
|
||||
if self.process.stdout in readylist:
|
||||
output = self.process.stdout.read(1024)
|
||||
if output and f"available at {self._url}" in output:
|
||||
break
|
||||
self.set_url(self._url)
|
||||
self.wait_until_loaded()
|
||||
|
||||
@Slot(str)
|
||||
def open_file(self, file_path: str):
|
||||
"""
|
||||
Open a file in the VSCode editor.
|
||||
|
||||
Args:
|
||||
file_path: The file path to open
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="open", content=f"file://{file_path}")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_vscode_event(self, content, _metadata):
|
||||
"""
|
||||
Handle the VSCode event. VSCode events are received as RawMessages.
|
||||
|
||||
Args:
|
||||
content: The content of the event
|
||||
metadata: The metadata of the event
|
||||
"""
|
||||
|
||||
# the message also contains the content but I think is fine for now to just emit the file path
|
||||
if not isinstance(content["data"], dict):
|
||||
return
|
||||
if "uri" not in content["data"]:
|
||||
return
|
||||
if not content["data"]["uri"].startswith("file://"):
|
||||
return
|
||||
file_path = content["data"]["uri"].split("file://")[1]
|
||||
self.file_saved.emit(file_path)
|
||||
|
||||
@Slot()
|
||||
def save_file(self):
|
||||
"""
|
||||
Save the file in the VSCode editor.
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="save")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot()
|
||||
def new_file(self):
|
||||
"""
|
||||
Create a new file in the VSCode editor.
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="new")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot()
|
||||
def close_file(self):
|
||||
"""
|
||||
Close the file in the VSCode editor.
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="close")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot(str)
|
||||
def write_file(self, content: str):
|
||||
"""
|
||||
Write content to the file in the VSCode editor.
|
||||
|
||||
Args:
|
||||
content: The content to write
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="write", content=content)
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot()
|
||||
def zen_mode(self):
|
||||
"""
|
||||
Toggle the Zen mode in the VSCode editor.
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="zenMode")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
@Slot(int, int)
|
||||
def set_cursor(self, line: int, column: int):
|
||||
"""
|
||||
Set the cursor in the VSCode editor.
|
||||
|
||||
Args:
|
||||
line: The line number
|
||||
column: The column number
|
||||
"""
|
||||
msg = VSCodeInstructionMessage(command="setCursor", content=f"{line},{column}")
|
||||
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
|
||||
|
||||
def cleanup_vscode(self):
|
||||
"""
|
||||
Cleanup the VSCode editor.
|
||||
"""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
return
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
self.process.wait()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the widget. This method is called from the dock area when the widget is removed.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
|
||||
self.cleanup_vscode()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = VSCodeEditor(gui_id="unknown")
|
||||
widget.show()
|
||||
app.exec_()
|
||||
widget.bec_dispatcher.disconnect_all()
|
||||
widget.client.shutdown()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['web_console.py']}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user