diff --git a/.github/actions/bw_install/action.yml b/.github/actions/bw_install/action.yml index 548a278c3..5b7c08017 100644 --- a/.github/actions/bw_install/action.yml +++ b/.github/actions/bw_install/action.yml @@ -53,6 +53,7 @@ runs: sudo apt-get update sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1 + sudo apt-get -y install ttyd - name: Install Python dependencies shell: bash diff --git a/.github/scripts/pr_issue_sync/pr_issue_sync.py b/.github/scripts/pr_issue_sync/pr_issue_sync.py deleted file mode 100644 index 82506cc7f..000000000 --- a/.github/scripts/pr_issue_sync/pr_issue_sync.py +++ /dev/null @@ -1,342 +0,0 @@ -import functools -import os -from typing import Literal - -import requests -from github import Auth, 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(auth=Auth.Token(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() diff --git a/.github/scripts/pr_issue_sync/requirements.txt b/.github/scripts/pr_issue_sync/requirements.txt deleted file mode 100644 index 9f191a9e3..000000000 --- a/.github/scripts/pr_issue_sync/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pydantic -pygithub \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 693a3a54c..5570667a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: required: false type: string +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: pull-requests: write diff --git a/.github/workflows/end2end-conda.yml b/.github/workflows/end2end-conda.yml index 65c644fd5..432903f1d 100644 --- a/.github/workflows/end2end-conda.yml +++ b/.github/workflows/end2end-conda.yml @@ -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,15 +23,16 @@ 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,4 +56,4 @@ jobs: with: name: pytest-logs path: ./logs/*.log - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f6f5a84d6..64f9c0c71 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -57,6 +57,14 @@ jobs: id: coverage run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/ + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: image-references + path: bec_widgets/tests/reference_failures/ + if-no-files-found: ignore + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/.github/workflows/sync-issues-pr.yml b/.github/workflows/sync-issues-pr.yml index f40facc84..326dfe532 100644 --- a/.github/workflows/sync-issues-pr.yml +++ b/.github/workflows/sync-issues-pr.yml @@ -2,7 +2,18 @@ name: Sync PR to Project on: pull_request: - types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize] + types: + [ + opened, + assigned, + unassigned, + edited, + ready_for_review, + converted_to_draft, + reopened, + synchronize, + closed, + ] jobs: sync-project: @@ -13,28 +24,12 @@ 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: Set up python environment - uses: actions/setup-python@v4 - with: - python-version: 3.11 - - - name: Checkout repo - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ github.event.pull_request.head.ref }} - - - name: Install dependencies - run: | - pip install -r ./.github/scripts/pr_issue_sync/requirements.txt - name: Sync PR to Project - run: | - python ./.github/scripts/pr_issue_sync/pr_issue_sync.py \ No newline at end of file + uses: bec-project/action-issue-sync-pr@v1 + 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 }} diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 2621e27e0..f88f7db64 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,4 +1,19 @@ +import os +import sys + +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +if sys.platform.startswith("linux"): + qt_platform = os.environ.get("QT_QPA_PLATFORM", "") + if qt_platform != "offscreen": + os.environ["QT_QPA_PLATFORM"] = "xcb" + +# Default QtAds configuration +QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) +QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True +) + __all__ = ["BECWidget", "SafeSlot", "SafeProperty"] diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 33500a1d4..8b8918498 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -1,12 +1,46 @@ from __future__ import annotations +from bec_lib import bec_logger + from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + +logger = bec_logger.logger + + +def dock_area( + object_name: str | None = None, profile: str | None = None, start_empty: bool = False +) -> BECDockArea: + """ + Create an advanced dock area using Qt Advanced Docking System. + Args: + object_name(str): The name of the advanced dock area. + profile(str|None): Optional profile to load; if None the "general" profile is used. + start_empty(bool): If True, start with an empty dock area when loading specified profile. + + Returns: + BECDockArea: The created advanced dock area. + + Note: + The "general" profile is mandatory and will always exist. If manually deleted, + it will be automatically recreated. + """ + # Default to "general" profile when called from CLI without specifying a profile + effective_profile = profile if profile is not None else "general" -def dock_area(object_name: str | None = None) -> BECDockArea: - _dock_area = BECDockArea(object_name=object_name, root_widget=True) - return _dock_area + widget = BECDockArea( + object_name=object_name, + restore_initial_profile=True, + root_widget=True, + profile_namespace="bec", + init_profile=effective_profile, + start_empty=start_empty, + ) + logger.info( + f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}" + ) + return widget def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates: diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 05c4f6d9f..224c3d2d0 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -27,10 +27,12 @@ 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.dock_area import BECDockArea +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.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton @@ -74,23 +76,28 @@ def __init__( circular_pixmap.fill(Qt.transparent) painter = QPainter(circular_pixmap) - painter.setRenderHints(QPainter.Antialiasing, True) + painter.setRenderHints(QPainter.RenderHint.Antialiasing, True) path = QPainterPath() path.addEllipse(0, 0, size, size) painter.setClipPath(path) - pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + pixmap = pixmap.scaled( + size, + size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) painter.drawPixmap(0, 0, pixmap) painter.end() self.icon_label.setPixmap(circular_pixmap) - self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter) + self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.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.AlignCenter) + self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter) # Main label self.main_label = QLabel(main_label) @@ -100,7 +107,7 @@ def __init__( font_main.setPointSize(14) font_main.setBold(True) self.main_label.setFont(font_main) - self.main_label.setAlignment(Qt.AlignCenter) + self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Shrink font if the default would wrap on this platform / DPI content_width = ( @@ -116,13 +123,13 @@ def __init__( self.layout.addWidget(self.main_label) - self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed) + self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.layout.addItem(self.spacer_top) # Description self.description_label = QLabel(description) self.description_label.setWordWrap(True) - self.description_label.setAlignment(Qt.AlignCenter) + self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.description_label) # Selector @@ -132,7 +139,9 @@ def __init__( else: self.selector = None - self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding) + self.spacer_bottom = QSpacerItem( + 0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) self.layout.addItem(self.spacer_bottom) # Action button @@ -152,7 +161,7 @@ def __init__( } """ ) - self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter) + self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter) def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): """ @@ -175,16 +184,25 @@ def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): metrics = QFontMetrics(font) label.setFont(font) label.setWordWrap(False) - label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width)) + label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.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", *args, **kwargs + self, + parent=None, + gui_id: str = None, + window_title="BEC Launcher", + launch_gui_class: str = None, + launch_gui_id: str = None, + *args, + **kwargs, ): super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) @@ -198,7 +216,7 @@ def __init__( self.toolbar = ModularToolBar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.toolbar) self.spacer = QWidget(self) - self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.toolbar.addWidget(self.spacer) self.toolbar.addWidget(self.dark_mode_button) @@ -211,11 +229,13 @@ def __init__( name="dock_area", icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), top_label="Get started", - main_label="BEC Dock Area", - description="Highly flexible and customizable dock area application with modular widgets.", - action_button=lambda: self.launch("dock_area"), - show_selector=False, + 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"), ) + self._refresh_dock_area_profiles(preserve_selection=False) self.available_auto_updates: dict[str, type[AutoUpdates]] = ( self._update_available_auto_updates() @@ -265,6 +285,11 @@ def __init__( 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, @@ -300,7 +325,7 @@ def register_tile( ) tile.setFixedWidth(self.TILE_SIZE[0]) tile.setMinimumHeight(self.TILE_SIZE[1]) - tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding) if action_button: tile.action_button.clicked.connect(action_button) if show_selector and selector_items: @@ -326,6 +351,69 @@ def register_tile( 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. + Sets the combobox to the last used profile or "general" if no selection 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.blockSignals(True) + selector.clear() + for profile in profiles: + 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 last profile or "general" + self._set_selector_to_default_profile(selector, profiles) + else: + # No selection to preserve, use last profile or "general" + 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 to the last used profile or "general" as fallback. + + Args: + selector(QComboBox): The combobox to set. + profiles(list[str]): List of available profiles. + """ + # 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 + + # Fall back to "general" profile + if "general" in profiles: + idx = selector.findText("general", 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, @@ -347,14 +435,14 @@ def launch( 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: - if name in 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) - + # 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) else: name = "dock_area" name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) @@ -372,32 +460,31 @@ def launch( if launch_script == "auto_update": auto_update = kwargs.pop("auto_update", None) - return self._launch_auto_update(auto_update) + return self._launch_auto_update(auto_update, geometry=geometry) 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) + return self._launch_widget(widget, geometry=geometry) launch = getattr(bw_launch, launch_script, None) if launch is None: raise ValueError(f"Launch script {launch_script} not found.") - result_widget = launch(name) - result_widget.resize(result_widget.minimumSizeHint()) + result_widget = launch(name, **kwargs) # 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 @@ -432,13 +519,15 @@ def _launch_custom_ui_file(self, ui_file: str | None) -> 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) -> AutoUpdates: + def _launch_auto_update( + self, auto_update: str, geometry: tuple[int, int, int, int] | None = None + ) -> AutoUpdates: if auto_update in self.available_auto_updates: auto_update_cls = self.available_auto_updates[auto_update] window = auto_update_cls() @@ -448,12 +537,14 @@ def _launch_auto_update(self, auto_update: str) -> AutoUpdates: 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]) -> QWidget: + def _launch_widget( + self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None + ) -> QWidget: name = pascal_to_snake(widget.__name__) WidgetContainerUtils.raise_for_invalid_name(name) @@ -462,11 +553,11 @@ def _launch_widget(self, widget: type[BECWidget]) -> QWidget: 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 @@ -491,6 +582,18 @@ def _open_auto_update(self): 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: + profile = None + else: + selection = tile.selector.currentText().strip() + profile = selection if selection else None + return self.launch("dock_area", profile=profile) + def _open_widget(self): """ Open a widget from the available widgets. @@ -502,6 +605,10 @@ def _open_widget(self): 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): """ @@ -538,6 +645,7 @@ def hide_launcher(self): self.hide() def showEvent(self, event): + self._refresh_dock_area_profiles() super().showEvent(event) self.setFixedSize(self.size()) @@ -546,10 +654,19 @@ def _launcher_is_last_widget(self, connections: dict) -> bool: Check if the launcher is the last widget in the application. """ - remaining_connections = [ - connection for connection in connections.values() if connection.parent_id != self.gui_id - ] - return len(remaining_connections) <= 4 + # 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 def _turn_off_the_lights(self, connections: dict): """ @@ -581,10 +698,13 @@ def closeEvent(self, event): self.hide() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover 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()) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py new file mode 100644 index 000000000..a80fea651 --- /dev/null +++ b/bec_widgets/applications/main_app.py @@ -0,0 +1,237 @@ +from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget + +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.developer_view.developer_view import DeveloperView +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.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.screen_utils import ( + apply_centered_size, + available_screen_geometry, + main_app_size_for_screen, +) +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow + + +class BECMainApp(BECMainWindow): + RPC = False + PLUGIN = False + + def __init__( + self, + parent=None, + *args, + anim_duration: int = ANIMATION_DURATION, + show_examples: bool = False, + **kwargs, + ): + super().__init__(parent=parent, *args, **kwargs) + self._show_examples = bool(show_examples) + + # --- Compose central UI (sidebar + stack) + self.sidebar = SideBar(parent=self, anim_duration=anim_duration) + self.stack = QStackedWidget(self) + + container = QWidget(self) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.sidebar, 0) + layout.addWidget(self.stack, 1) + self.setCentralWidget(container) + + # Mapping for view switching + self._view_index: dict[str, int] = {} + self._current_view_id: str | None = None + self.sidebar.view_selected.connect(self._on_view_selected) + + self._add_views() + + def _add_views(self): + self.add_section("BEC Applications", "bec_apps") + self.dock_area = DockAreaView(self) + self.device_manager = DeviceManagerView(self) + self.developer_view = DeveloperView(self) + + self.add_view( + icon="widgets", + title="Dock Area", + id="dock_area", + widget=self.dock_area, + mini_text="Docks", + ) + self.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=self.device_manager, + mini_text="DM", + ) + self.add_view( + icon="code_blocks", + title="IDE", + widget=self.developer_view, + id="developer_view", + exclusive=True, + ) + + if self._show_examples: + self.add_section("Examples", "examples") + waveform_view_popup = WaveformViewPopup( + parent=self, id="waveform_view_popup", title="Waveform Plot" + ) + waveform_view_stack = WaveformViewInline( + parent=self, id="waveform_view_stack", title="Waveform Plot" + ) + + self.add_view( + icon="show_chart", + title="Waveform With Popup", + id="waveform_popup", + widget=waveform_view_popup, + mini_text="Popup", + ) + self.add_view( + icon="show_chart", + title="Waveform InLine Stack", + id="waveform_stack", + widget=waveform_view_stack, + mini_text="Stack", + ) + + self.set_current("dock_area") + self.sidebar.add_dark_mode_item() + + # --- Public API ------------------------------------------------------ + def add_section(self, title: str, id: str, position: int | None = None): + return self.sidebar.add_section(title, id, position) + + def add_separator(self): + return self.sidebar.add_separator() + + def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None): + return self.sidebar.add_dark_mode_item(id=id, position=position) + + def add_view( + self, + *, + icon: str, + title: str, + id: str, + widget: QWidget, + mini_text: str | None = None, + position: int | None = None, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Register a view in the stack and create a matching nav item in the sidebar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the view/item. + widget(QWidget): The widget to add to the stack. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + + + """ + item = self.sidebar.add_item( + icon=icon, + title=title, + id=id, + mini_text=mini_text, + position=position, + from_top=from_top, + toggleable=toggleable, + exclusive=exclusive, + ) + # Wrap plain widgets into a ViewBase so enter/exit hooks are available + if isinstance(widget, ViewBase): + view_widget = widget + view_widget.view_id = id + view_widget.view_title = title + else: + view_widget = ViewBase(content=widget, parent=self, id=id, title=title) + + idx = self.stack.addWidget(view_widget) + self._view_index[id] = idx + return item + + def set_current(self, id: str) -> None: + if id in self._view_index: + self.sidebar.activate_item(id) + + # Internal: route sidebar selection to the stack + def _on_view_selected(self, vid: str) -> None: + # Determine current view + current_index = self.stack.currentIndex() + current_view = ( + self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None + ) + + # Ask current view whether we may leave + if current_view is not None and hasattr(current_view, "on_exit"): + may_leave = current_view.on_exit() + if may_leave is False: + # Veto: restore previous highlight without re-emitting selection + if self._current_view_id is not None: + self.sidebar.activate_item(self._current_view_id, emit_signal=False) + return + + # Proceed with switch + idx = self._view_index.get(vid) + if idx is None or not (0 <= idx < self.stack.count()): + return + self.stack.setCurrentIndex(idx) + new_view = self.stack.widget(idx) + self._current_view_id = vid + if hasattr(new_view, "on_enter"): + new_view.on_enter() + + +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 + import argparse + import sys + + parser = argparse.ArgumentParser(description="BEC Main Application") + parser.add_argument( + "--examples", action="store_true", help="Show the Examples section with waveform demo views" + ) + # Let Qt consume the remaining args + args, qt_args = parser.parse_known_args(sys.argv[1:]) + + app = QApplication([sys.argv[0], *qt_args]) + apply_theme("dark") + w = BECMainApp(show_examples=args.examples) + + screen_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()) + + w.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/examples/general_app/__init__.py b/bec_widgets/applications/navigation_centre/__init__.py similarity index 100% rename from bec_widgets/examples/general_app/__init__.py rename to bec_widgets/applications/navigation_centre/__init__.py diff --git a/bec_widgets/applications/navigation_centre/reveal_animator.py b/bec_widgets/applications/navigation_centre/reveal_animator.py new file mode 100644 index 000000000..714f69da3 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/reveal_animator.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation +from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget + +ANIMATION_DURATION = 500 # ms + + +class RevealAnimator: + """Animate reveal/hide for a single widget using opacity + max W/H. + + This keeps the widget always visible to avoid jitter from setVisible(). + Collapsed state: opacity=0, maxW=0, maxH=0. + Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height(). + """ + + def __init__( + self, + widget: QWidget, + duration: int = ANIMATION_DURATION, + easing: QEasingCurve.Type = QEasingCurve.InOutCubic, + initially_revealed: bool = False, + *, + animate_opacity: bool = True, + animate_width: bool = True, + animate_height: bool = True, + ): + self.widget = widget + self.animate_opacity = animate_opacity + self.animate_width = animate_width + self.animate_height = animate_height + # Opacity effect + self.fx = QGraphicsOpacityEffect(widget) + widget.setGraphicsEffect(self.fx) + # Animations + self.opacity_anim = ( + QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None + ) + self.width_anim = ( + QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None + ) + self.height_anim = ( + QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None + ) + for anim in (self.opacity_anim, self.width_anim, self.height_anim): + if anim is not None: + anim.setDuration(duration) + anim.setEasingCurve(easing) + # Initialize to requested state + self.set_immediate(initially_revealed) + + def _natural_sizes(self) -> tuple[int, int]: + sh = self.widget.sizeHint() + w = max(sh.width(), 1) + h = max(sh.height(), 1) + return w, h + + def set_immediate(self, revealed: bool): + """ + Immediately set the widget to the target revealed/collapsed state. + + Args: + revealed(bool): True to reveal, False to collapse. + """ + w, h = self._natural_sizes() + if self.animate_opacity: + self.fx.setOpacity(1.0 if revealed else 0.0) + if self.animate_width: + self.widget.setMaximumWidth(w if revealed else 0) + if self.animate_height: + self.widget.setMaximumHeight(h if revealed else 0) + + def setup(self, reveal: bool): + """ + Prepare animations to transition to the target revealed/collapsed state. + + Args: + reveal(bool): True to reveal, False to collapse. + """ + # Prepare animations from current state to target + target_w, target_h = self._natural_sizes() + if self.opacity_anim is not None: + self.opacity_anim.setStartValue(self.fx.opacity()) + self.opacity_anim.setEndValue(1.0 if reveal else 0.0) + if self.width_anim is not None: + self.width_anim.setStartValue(self.widget.maximumWidth()) + self.width_anim.setEndValue(target_w if reveal else 0) + if self.height_anim is not None: + self.height_anim.setStartValue(self.widget.maximumHeight()) + self.height_anim.setEndValue(target_h if reveal else 0) + + def add_to_group(self, group: QParallelAnimationGroup): + """ + Add the prepared animations to the given animation group. + + Args: + group(QParallelAnimationGroup): The animation group to add to. + """ + if self.opacity_anim is not None: + group.addAnimation(self.opacity_anim) + if self.width_anim is not None: + group.addAnimation(self.width_anim) + if self.height_anim is not None: + group.addAnimation(self.height_anim) + + def animations(self): + """ + Get a list of all animations (non-None) for adding to a group. + """ + return [ + anim + for anim in (self.opacity_anim, self.height_anim, self.width_anim) + if anim is not None + ] diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py new file mode 100644 index 000000000..6354cafe2 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtWidgets +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal +from qtpy.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QScrollArea, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty, SafeSlot +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION +from bec_widgets.applications.navigation_centre.side_bar_components import ( + DarkModeNavItem, + NavigationItem, + SectionHeader, + SideBarSeparator, +) + + +class SideBar(QScrollArea): + view_selected = Signal(str) + toggled = Signal(bool) + + def __init__( + self, + parent=None, + title: str = "Control Panel", + collapsed_width: int = 56, + expanded_width: int = 250, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("SideBar") + + # private attributes + self._is_expanded = False + self._collapsed_width = collapsed_width + self._expanded_width = expanded_width + self._anim_duration = anim_duration + + # containers + self.components = {} + self._item_opts: dict[str, dict] = {} + + # Scroll area properties + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFrameShape(QtWidgets.QFrame.NoFrame) + self.setFixedWidth(self._collapsed_width) + + # Content widget holding buttons for switching views + self.content = QWidget(self) + self.content_layout = QVBoxLayout(self.content) + self.content_layout.setContentsMargins(0, 0, 0, 0) + self.content_layout.setSpacing(4) + self.setWidget(self.content) + + # Track active navigation item + self._active_id = None + + # Top row with title and toggle button + self.toggle_row = QWidget(self) + self.toggle_row_layout = QHBoxLayout(self.toggle_row) + + self.title_label = QLabel(title, self) + self.title_label.setObjectName("TopTitle") + self.title_label.setStyleSheet("font-weight: 600;") + self.title_fx = QGraphicsOpacityEffect(self.title_label) + self.title_label.setGraphicsEffect(self.title_fx) + self.title_fx.setOpacity(0.0) + self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift + + self.toggle = QToolButton(self) + self.toggle.setCheckable(False) + self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False)) + self.toggle.clicked.connect(self.on_expand) + + self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter) + self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter) + + # To push the content up always + self._bottom_spacer = QtWidgets.QSpacerItem( + 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + ) + + # Add core widgets to layout + self.content_layout.addWidget(self.toggle_row) + self.content_layout.addItem(self._bottom_spacer) + + # Animations + self.width_anim = QPropertyAnimation(self, b"bar_width") + self.width_anim.setDuration(self._anim_duration) + self.width_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.title_anim = QPropertyAnimation(self.title_fx, b"opacity") + self.title_anim.setDuration(self._anim_duration) + self.title_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.group = QParallelAnimationGroup(self) + self.group.addAnimation(self.width_anim) + self.group.addAnimation(self.title_anim) + self.group.finished.connect(self._on_anim_finished) + + app = QtWidgets.QApplication.instance() + if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"): + app.theme.theme_changed.connect(self._on_theme_changed) + + @SafeProperty(int) + def bar_width(self) -> int: + """ + Get the current width of the side bar. + + Returns: + int: The current width of the side bar. + """ + return self.width() + + @bar_width.setter + def bar_width(self, width: int): + """ + Set the width of the side bar. + + Args: + width(int): The new width of the side bar. + """ + self.setFixedWidth(width) + + @SafeProperty(bool) + def is_expanded(self) -> bool: + """ + Check if the side bar is expanded. + + Returns: + bool: True if the side bar is expanded, False otherwise. + """ + return self._is_expanded + + @SafeSlot() + @SafeSlot(bool) + def on_expand(self): + """ + Toggle the expansion state of the side bar. + """ + self._is_expanded = not self._is_expanded + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + + if self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter) + + self.group.stop() + # Setting limits for animations of the side bar + self.width_anim.setStartValue(self.width()) + self.width_anim.setEndValue( + self._expanded_width if self._is_expanded else self._collapsed_width + ) + self.title_anim.setStartValue(self.title_fx.opacity()) + self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0) + + # Setting limits for animations of the components + for comp in self.components.values(): + if hasattr(comp, "setup_animations"): + comp.setup_animations(self._is_expanded) + + self.group.start() + if self._is_expanded: + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + self.toggled.emit(self._is_expanded) + + @SafeSlot() + def _on_anim_finished(self): + if not self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter) + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + + @SafeSlot(str) + def _on_theme_changed(self, theme_name: str): + # Refresh toggle arrow icon so it picks up the new theme + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + # Refresh each component that supports it + for comp in self.components.values(): + if hasattr(comp, "refresh_theme"): + comp.refresh_theme() + else: + comp.style().unpolish(comp) + comp.style().polish(comp) + comp.update() + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader: + """ + Add a section header to the side bar. + + Args: + title(str): The title of the section. + id(str): Unique ID for the section. + position(int, optional): Position to insert the section header. + + Returns: + SectionHeader: The created section header. + + """ + header = SectionHeader(self, title, anim_duration=self._anim_duration) + position = position if position is not None else self.content_layout.count() - 1 + self.content_layout.insertWidget(position, header) + for anim in header.animations: + self.group.addAnimation(anim) + self.components[id] = header + return header + + def add_separator( + self, *, from_top: bool = True, position: int | None = None + ) -> SideBarSeparator: + """ + Add a separator line to the side bar. Separators are treated like regular + items; you can place multiple separators anywhere using `from_top` and `position`. + """ + line = SideBarSeparator(self) + line.setStyleSheet("margin:12px;") + self._insert_nav_item(line, from_top=from_top, position=position) + return line + + def add_item( + self, + icon: str, + title: str, + id: str, + mini_text: str | None = None, + position: int | None = None, + *, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Add a navigation item to the side bar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the nav item. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + """ + item = NavigationItem( + parent=self, + title=title, + icon_name=icon, + mini_text=mini_text, + toggleable=toggleable, + exclusive=exclusive, + anim_duration=self._anim_duration, + ) + self._insert_nav_item(item, from_top=from_top, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + # Connect activation to activation logic, passing id unchanged + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def activate_item(self, target_id: str, *, emit_signal: bool = True): + target = self.components.get(target_id) + if target is None: + return + # Non-toggleable acts like an action: do not change any toggled states + if hasattr(target, "toggleable") and not target.toggleable: + self._active_id = target_id + if emit_signal: + self.view_selected.emit(target_id) + return + + is_exclusive = getattr(target, "exclusive", True) + if is_exclusive: + # Radio-like behavior among exclusive items only + for comp_id, comp in self.components.items(): + if not isinstance(comp, NavigationItem): + continue + if comp is target: + comp.set_active(True) + else: + # Only untoggle other items that are also exclusive + if getattr(comp, "exclusive", True): + comp.set_active(False) + # Leave non-exclusive items as they are + else: + # Non-exclusive toggles independently + target.set_active(not target.is_active()) + + self._active_id = target_id + if emit_signal: + self.view_selected.emit(target_id) + + def add_dark_mode_item( + self, id: str = "dark_mode", position: int | None = None + ) -> DarkModeNavItem: + """ + Add a dark mode toggle item to the side bar. + + Args: + id(str): Unique ID for the dark mode item. + position(int, optional): Position to insert the dark mode item. + + Returns: + DarkModeNavItem: The created dark mode navigation item. + """ + item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration) + # compute bottom insertion point (same semantics as from_top=False) + self._insert_nav_item(item, from_top=False, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def _insert_nav_item( + self, item: QWidget, *, from_top: bool = True, position: int | None = None + ): + if from_top: + base_index = self.content_layout.indexOf(self._bottom_spacer) + pos = base_index if position is None else min(base_index, position) + else: + base = self.content_layout.indexOf(self._bottom_spacer) + 1 + pos = base if position is None else base + max(0, position) + self.content_layout.insertWidget(pos, item) diff --git a/bec_widgets/applications/navigation_centre/side_bar_components.py b/bec_widgets/applications/navigation_centre/side_bar_components.py new file mode 100644 index 000000000..67bb7666f --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar_components.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtCore +from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty +from bec_widgets.applications.navigation_centre.reveal_animator import ( + ANIMATION_DURATION, + RevealAnimator, +) + + +def get_on_primary(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("ON_PRIMARY") + return "#FFFFFF" + + +def get_fg(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("FG") + return "#FFFFFF" + + +class SideBarSeparator(QFrame): + """A horizontal line separator for use in SideBar.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SideBarSeparator") + self.setFrameShape(QFrame.NoFrame) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(2) + self.setProperty("variant", "separator") + + +class SectionHeader(QWidget): + """A section header with a label and a horizontal line below.""" + + def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION): + super().__init__(parent) + self.setObjectName("SectionHeader") + + self.lbl = QLabel(text, self) + self.lbl.setObjectName("SectionHeaderLabel") + self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False) + + self.line = SideBarSeparator(self) + + lay = QVBoxLayout(self) + # keep your margins/spacing preferences here if needed + lay.setContentsMargins(12, 0, 12, 0) + lay.setSpacing(6) + lay.addWidget(self.lbl) + lay.addWidget(self.line) + + self.animations = self.build_animations() + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return self._reveal.animations() + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self._reveal.setup(expanded) + + +class NavigationItem(QWidget): + """A nav tile with an icon + labels and an optional expandable body. + Provides animations for collapsed/expanded sidebar states via + build_animations()/setup_animations(), similar to SectionHeader. + """ + + activated = QtCore.Signal() + + def __init__( + self, + parent=None, + *, + title: str, + icon_name: str, + mini_text: str | None = None, + toggleable: bool = True, + exclusive: bool = True, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("NavigationItem") + + # Private attributes + self._title = title + self._icon_name = icon_name + self._mini_text = mini_text or title + self._toggleable = toggleable + self._toggled = False + self._exclusive = exclusive + + # Main Icon + self.icon_btn = QToolButton(self) + self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False)) + self.icon_btn.setAutoRaise(True) + self._icon_size_collapsed = QtCore.QSize(20, 20) + self._icon_size_expanded = QtCore.QSize(26, 26) + self.icon_btn.setIconSize(self._icon_size_collapsed) + # Remove QToolButton hover/pressed background/outline + self.icon_btn.setStyleSheet( + """ + QToolButton:hover { background: transparent; border: none; } + QToolButton:pressed { background: transparent; border: none; } + """ + ) + + # Mini label below icon + self.mini_lbl = QLabel(self._mini_text, self) + self.mini_lbl.setObjectName("NavMiniLabel") + self.mini_lbl.setAlignment(Qt.AlignCenter) + self.mini_lbl.setStyleSheet("font-size: 10px;") + self.reveal_mini_lbl = RevealAnimator( + widget=self.mini_lbl, + initially_revealed=True, + animate_width=False, + duration=anim_duration, + ) + + # Container for icon + mini label + self.mini_icon = QWidget(self) + mini_lay = QVBoxLayout(self.mini_icon) + mini_lay.setContentsMargins(0, 2, 0, 2) + mini_lay.setSpacing(2) + mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter) + mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter) + + # Title label + self.title_lbl = QLabel(self._title, self) + self.title_lbl.setObjectName("NavTitleLabel") + self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.title_lbl.setStyleSheet("font-size: 13px;") + self.reveal_title_lbl = RevealAnimator( + widget=self.title_lbl, + initially_revealed=False, + animate_height=False, + duration=anim_duration, + ) + self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift + + lay = QHBoxLayout(self) + lay.setContentsMargins(12, 2, 12, 2) + lay.setSpacing(6) + lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop) + lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter) + + self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize") + self.icon_size_anim.setDuration(anim_duration) + self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic) + + # Connect icon button to emit activation + self.icon_btn.clicked.connect(self._emit_activated) + self.setMouseTracking(True) + self.setAttribute(Qt.WA_StyledBackground, True) + + def is_active(self) -> bool: + """Return whether the item is currently active/selected.""" + return self.property("toggled") is True + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return ( + self.reveal_title_lbl.animations() + + self.reveal_mini_lbl.animations() + + [self.icon_size_anim] + ) + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self.reveal_mini_lbl.setup(not expanded) + self.reveal_title_lbl.setup(expanded) + self.icon_size_anim.setStartValue(self.icon_btn.iconSize()) + self.icon_size_anim.setEndValue( + self._icon_size_expanded if expanded else self._icon_size_collapsed + ) + + def set_visible(self, visible: bool): + """Set visibility of the title label.""" + self.title_lbl.setVisible(visible) + + def _emit_activated(self): + self.activated.emit() + + def set_active(self, active: bool): + """ + Set the active/selected state of the item. + + Args: + active(bool): True to set active, False to deactivate. + """ + self.setProperty("toggled", active) + self.toggled = active + # ensure style refresh + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mousePressEvent(self, event): + self.activated.emit() + super().mousePressEvent(event) + + @SafeProperty(bool) + def toggleable(self) -> bool: + """ + Whether the item is toggleable (like a button) or not (like an action). + + Returns: + bool: True if toggleable, False otherwise. + """ + return self._toggleable + + @toggleable.setter + def toggleable(self, value: bool): + """ + Set whether the item is toggleable (like a button) or not (like an action). + Args: + value(bool): True to make toggleable, False otherwise. + """ + self._toggleable = bool(value) + + @SafeProperty(bool) + def toggled(self) -> bool: + """ + Whether the item is currently toggled/selected. + + Returns: + bool: True if toggled, False otherwise. + """ + return self._toggled + + @toggled.setter + def toggled(self, value: bool): + """ + Set whether the item is currently toggled/selected. + + Args: + value(bool): True to set toggled, False to untoggle. + """ + self._toggled = value + if value: + new_icon = material_icon( + self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False + ) + else: + new_icon = material_icon( + self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False + ) + self.icon_btn.setIcon(new_icon) + # Re-polish so QSS applies correct colors to icon/labels + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + @SafeProperty(bool) + def exclusive(self) -> bool: + """ + Whether the item is exclusive in its toggle group. + + Returns: + bool: True if exclusive, False otherwise. + """ + return self._exclusive + + @exclusive.setter + def exclusive(self, value: bool): + """ + Set whether the item is exclusive in its toggle group. + + Args: + value(bool): True to make exclusive, False otherwise. + """ + self._exclusive = bool(value) + + def refresh_theme(self): + # Recompute icon/label colors according to current theme and state + # Trigger the toggled setter to rebuild the icon with the correct color + self.toggled = self._toggled + # Ensure QSS-driven text/icon colors refresh + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + +class DarkModeNavItem(NavigationItem): + """Bottom action item that toggles app theme and updates its icon/text.""" + + def __init__( + self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION + ): + super().__init__( + parent=parent, + title="Dark mode", + icon_name="dark_mode", + mini_text="Dark", + toggleable=False, # action-like, no selection highlight changes + exclusive=False, + anim_duration=anim_duration, + ) + self._id = id + self._sync_from_qapp_theme() + self.activated.connect(self.toggle_theme) + + def _qapp_dark_enabled(self) -> bool: + qapp = QApplication.instance() + return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark") + + def _sync_from_qapp_theme(self): + is_dark = self._qapp_dark_enabled() + # Update labels + self.title_lbl.setText("Light mode" if is_dark else "Dark mode") + self.mini_lbl.setText("Light" if is_dark else "Dark") + # Update icon + self.icon_btn.setIcon( + material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False) + ) + + def refresh_theme(self): + self._sync_from_qapp_theme() + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + def toggle_theme(self): + """Toggle application theme and update icon/text.""" + from bec_widgets.utils.colors import apply_theme + + is_dark = self._qapp_dark_enabled() + + apply_theme("light" if is_dark else "dark") + self._sync_from_qapp_theme() diff --git a/bec_widgets/widgets/editors/vscode/__init__.py b/bec_widgets/applications/views/__init__.py similarity index 100% rename from bec_widgets/widgets/editors/vscode/__init__.py rename to bec_widgets/applications/views/__init__.py diff --git a/bec_widgets/applications/views/developer_view/__init__.py b/bec_widgets/applications/views/developer_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py new file mode 100644 index 000000000..6f177c752 --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -0,0 +1,60 @@ +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget +from bec_widgets.applications.views.view import ViewBase + + +class DeveloperView(ViewBase): + """ + A view for users to write scripts and macros and execute them within the application. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.developer_widget = DeveloperWidget(parent=self) + self.set_content(self.developer_widget) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + developer_view = DeveloperView() + _app.add_view( + icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True + ) + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py new file mode 100644 index 000000000..a737b1453 --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import re + +import markdown +from bec_lib.endpoints import MessageEndpoints +from bec_lib.script_executor import upload_script +from bec_qthemes import material_icon +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QTextEdit + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.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.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.utility.ide_explorer.ide_explorer import IDEExplorer + + +def markdown_to_html(md_text: str) -> str: + """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" + + # Preprocess: convert consecutive >>> lines to Python code blocks + def replace_python_examples(match): + indent = match.group(1) + examples = match.group(2) + # Remove >>> prefix and clean up the code + lines = [] + for line in examples.strip().split("\n"): + line = line.strip() + if line.startswith(">>> "): + lines.append(line[4:]) # Remove '>>> ' + elif line.startswith(">>>"): + lines.append(line[3:]) # Remove '>>>' + code = "\n".join(lines) + + return f"{indent}```python\n{indent}{code}\n{indent}```" + + # Match one or more consecutive >>> lines (with same indentation) + pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" + md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) + + extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] + html = markdown.markdown( + md_text, + extensions=extensions, + extension_configs={ + "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} + }, + output_format="html", + ) + + # Remove hardcoded background colors that conflict with themes + html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) + html = re.sub(r"background: #[^;]*;", "", html) + + # Add CSS to force code blocks to wrap + css = """ + + """ + + return css + html + + +class DeveloperWidget(DockAreaWidget): + RPC = False + PLUGIN = False + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, variant="compact", **kwargs) + + # Promote toolbar above the dock manager provided by the base class + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + self._root_layout.insertWidget(0, self.toolbar) + + # Initialize the widgets + self.explorer = IDEExplorer(self) + self.explorer.setObjectName("Explorer") + + self.console = BECShell(self) + self.console.setObjectName("BEC Shell") + self.terminal = WebConsole(self) + self.terminal.setObjectName("Terminal") + self.monaco = MonacoDock(self) + self.monaco.setObjectName("MonacoEditor") + self.monaco.save_enabled.connect(self._on_save_enabled_update) + self.plotting_ads = BECDockArea( + self, + mode="plot", + default_add_direction="bottom", + profile_namespace="developer_plotting", + auto_profile_namespace=False, + enable_profile_management=False, + variant="compact", + ) + self.plotting_ads.setObjectName("PlottingArea") + self.signature_help = QTextEdit(self) + self.signature_help.setObjectName("Signature Help") + self.signature_help.setAcceptRichText(True) + self.signature_help.setReadOnly(True) + self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + opt = self.signature_help.document().defaultTextOption() + opt.setWrapMode(opt.WrapMode.WrapAnywhere) + self.signature_help.document().setDefaultTextOption(opt) + self.monaco.signature_help.connect( + lambda text: self.signature_help.setHtml(markdown_to_html(text)) + ) + self._current_script_id: str | None = None + self.script_editor_tab = None + + self._initialize_layout() + + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) + self.monaco.focused_editor.connect(self._on_focused_editor_changed) + + self.toolbar.show_bundles(["save", "execution", "settings"]) + + def _initialize_layout(self) -> None: + """Create the default dock arrangement for the developer workspace.""" + + # Monaco editor as the central dock + self.monaco_dock = self.new( + self.monaco, + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, + show_settings_action=False, + title_buttons={"float": False, "close": False, "menu": False}, + # promote_central=True, + ) + + # Explorer on the left without a title bar + self.explorer_dock = self.new( + self.explorer, + where="left", + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, + ) + + # Console and terminal tabbed along the bottom + self.console_dock = self.new( + self.console, + relative_to=self.monaco_dock, + where="bottom", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True, "close": False}, + ) + self.terminal_dock = self.new( + self.terminal, + closable=False, + floatable=False, + movable=False, + tab_with=self.console_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, + ) + + # Plotting area on the right with signature help tabbed alongside + self.plotting_ads_dock = self.new( + self.plotting_ads, + where="right", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True}, + ) + self.signature_dock = self.new( + self.signature_help, + closable=False, + floatable=False, + movable=False, + tab_with=self.plotting_ads_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, + ) + + self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3]) + + def init_developer_toolbar(self): + """Initialize the developer toolbar with necessary actions and widgets.""" + save_button = MaterialIconAction( + icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self + ) + save_button.action.triggered.connect(self.on_save) + self.toolbar.components.add_safe("save", save_button) + + save_as_button = MaterialIconAction( + icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self + ) + self.toolbar.components.add_safe("save_as", save_as_button) + save_as_button.action.triggered.connect(self.on_save_as) + + save_bundle = ToolbarBundle("save", self.toolbar.components) + save_bundle.add_action("save") + save_bundle.add_action("save_as") + self.toolbar.add_bundle(save_bundle) + + run_action = MaterialIconAction( + icon_name="play_arrow", + tooltip="Run current file", + label_text="Run", + filled=True, + parent=self, + ) + run_action.action.triggered.connect(self.on_execute) + self.toolbar.components.add_safe("run", run_action) + + stop_action = MaterialIconAction( + icon_name="stop", + tooltip="Stop current execution", + label_text="Stop", + filled=True, + parent=self, + ) + stop_action.action.triggered.connect(self.on_stop) + self.toolbar.components.add_safe("stop", stop_action) + + execution_bundle = ToolbarBundle("execution", self.toolbar.components) + execution_bundle.add_action("run") + execution_bundle.add_action("stop") + self.toolbar.add_bundle(execution_bundle) + + vim_action = MaterialIconAction( + icon_name="vim", + tooltip="Toggle Vim Mode", + label_text="Vim", + filled=True, + parent=self, + checkable=True, + ) + self.toolbar.components.add_safe("vim", vim_action) + vim_action.action.triggered.connect(self.on_vim_triggered) + + settings_bundle = ToolbarBundle("settings", self.toolbar.components) + settings_bundle.add_action("vim") + self.toolbar.add_bundle(settings_bundle) + + save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + save_shortcut.activated.connect(self.on_save) + save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) + save_as_shortcut.activated.connect(self.on_save_as) + + def _open_new_file(self, file_name: str, scope: str): + self.monaco.open_file(file_name, scope) + + # Set read-only mode for shared files + if "shared" in scope: + self.monaco.set_file_readonly(file_name, True) + + # Add appropriate icon based on file type + if "script" in scope: + # Use script icon for script files + icon = material_icon("script", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + elif "macro" in scope: + # Use function icon for macro files + icon = material_icon("function", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + + @SafeSlot() + def on_save(self): + """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) + def _on_save_enabled_update(self, enabled: bool): + self.toolbar.components.get_action("save").action.setEnabled(enabled) + self.toolbar.components.get_action("save_as").action.setEnabled(enabled) + + @SafeSlot() + def on_execute(self): + """Upload and run the currently focused script in the Monaco editor.""" + self.script_editor_tab = self.monaco.last_focused_editor + if not self.script_editor_tab: + return + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return + 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 + self._current_script_id = value + self._update_subscription(value, old_script_id) + + def _update_subscription(self, new_script_id: str | None, old_script_id: str | None): + if old_script_id is not None: + self.bec_dispatcher.disconnect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id) + ) + if new_script_id is not None: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id) + ) + + @SafeSlot(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): + """ + Handle script execution info messages to update the editor highlights. + Args: + content (dict): The content of the message containing execution info. + metadata (dict): Additional metadata for the message. + """ + print(f"Script execution info: {content}") + current_lines = content.get("current_lines") + if self.script_editor_tab is None: + return + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return + if not current_lines: + widget.clear_highlighted_lines() + return + line_number = current_lines[0] + widget.clear_highlighted_lines() + widget.set_highlighted_lines(line_number, line_number) + + def cleanup(self): + """Clean up resources used by the developer widget.""" + self.delete_all() + return super().cleanup() + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/__init__.py b/bec_widgets/applications/views/device_manager_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py new file mode 100644 index 000000000..b507c9d9f --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py @@ -0,0 +1,2 @@ +from .config_choice_dialog import ConfigChoiceDialog +from .device_form_dialog import DeviceFormDialog diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py new file mode 100644 index 000000000..db91597fc --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py @@ -0,0 +1,49 @@ +"""Dialog to choose config loading method: replace, add or cancel.""" + +from enum import IntEnum + +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout + + +class ConfigChoiceDialog(QDialog): + class Result(IntEnum): + CANCEL = QDialog.Rejected + ADD = 2 + REPLACE = 3 + + def __init__( + self, + parent=None, + custom_label: str = "Do you want to replace the current config or add to it?", + ): + super().__init__(parent) + self.setWindowTitle("Load Config") + + layout = QVBoxLayout(self) + + label = QLabel(custom_label) + label.setWordWrap(True) + layout.addWidget(label) + + # Use QDialogButtonBox for native layout + self.button_box = QDialogButtonBox(self) + self.cancel_btn = self.button_box.addButton( + "Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept... + ) + self.replace_btn = self.button_box.addButton( + "Replace", QDialogButtonBox.ButtonRole.AcceptRole + ) + self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole) + + layout.addWidget(self.button_box) + + for btn in [self.replace_btn, self.add_btn, self.cancel_btn]: + btn.setMinimumWidth(80) + btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + # Connections using native done(int) + self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE)) + self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD)) + self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL)) + + self.replace_btn.setFocus() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py new file mode 100644 index 000000000..1f4b574fc --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -0,0 +1,447 @@ +"""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 +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components import OphydValidation +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, +) +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + validate_name, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + format_error_to_md, +) + +DEFAULT_DEVICE = "CustomDevice" +_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] + + +logger = bec_logger.logger + + +class DeviceManagerOphydValidationDialog(QtWidgets.QDialog): + """Popup dialog to test Ophyd device configurations interactively.""" + + def __init__(self, parent=None, config: dict | None = None): # type:ignore + super().__init__(parent) + self.setWindowTitle("Device Manager Ophyd Test") + self._config_status = ConfigStatus.UNKNOWN.value + self._connection_status = ConnectionStatus.UNKNOWN.value + self._validated_config: dict = {} + self._validation_msg: str = "" + + layout = QtWidgets.QVBoxLayout(self) + + # Core test widget + self.device_manager_ophyd_test = OphydValidation() + layout.addWidget(self.device_manager_ophyd_test) + + # Log/Markdown box for messages + self.text_box = QtWidgets.QTextEdit() + self.text_box.setReadOnly(True) + layout.addWidget(self.text_box) + + # 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) + + # Dialog Buttons: equal size, stacked horizontally + button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) + for button in button_box.buttons(): + button.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed + ) + button.clicked.connect(self.accept) + # button_box.setCenterButtons(False) + layout.addWidget(button_box) + self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated) + self._resize_dialog() + self.finished.connect(self._finished) + + # 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() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 4:3 ratio + height = int(screen_height * 0.7) + width = int(height * (4 / 3)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (4 / 3)) + + self.resize(width, height) + + def _on_device_validated( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ): + device_name = device_config.get("name", "") + self._config_status = config_status + self._connection_status = connection_status + self._validated_config = device_config + self._validation_msg = validation_msg + self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg)) + + @SafeSlot(int) + def _finished(self, state: int): + self.device_manager_ophyd_test.close() + self.device_manager_ophyd_test.deleteLater() + + @property + def validation_result(self) -> tuple[dict, int, int, str]: + """ + Return the result of the validation as a tuple of + + Returns: + result (Tuple[dict, int, int]): A tuple containing: + validated_config (dict): The validated device configuration. + config_status (int): The configuration status. + connection_status (int): The connection status. + + """ + return ( + self._validated_config, + self._config_status, + self._connection_status, + self._validation_msg, + ) + + +class DeviceFormDialog(QtWidgets.QDialog): + + # Signal emitted when device configuration is accepted, only + # emitted when the user clicks the "Add Device" button + # The integer values indicate if the device config was + # validated: config_status, connection_status + accepted_data = QtCore.Signal(dict, int, int, str, str) + + def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore + super().__init__(parent) + # Track old device name if config is edited + self._old_device_name: str = "" + + # Config validation result + self._validation_result: tuple[dict, int, int, str] = ( + {}, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "", + ) + # Group to variants mapping + self._group_variants: dict[str, list[str]] = { + group: [variant for variant in variants.keys()] + for group, variants in OPHYD_DEVICE_TEMPLATES.items() + } + + self._control_widgets: dict[str, QtWidgets.QWidget] = {} + + # Setup layout + self.setWindowTitle("Device Config Dialog") + layout = QtWidgets.QVBoxLayout(self) + + # Control panel + self._control_box = self.create_control_panel() + layout.addWidget(self._control_box) + + # Device config template display + self._device_config_template = DeviceConfigTemplate(parent=self) + self._frame = QtWidgets.QFrame() + self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self._frame.setFrameShadow(QtWidgets.QFrame.Raised) + frame_layout = QtWidgets.QVBoxLayout(self._frame) + frame_layout.addWidget(self._device_config_template) + layout.addWidget(self._frame) + + # Custom buttons + self.add_btn = QtWidgets.QPushButton(add_btn_text) + self.test_connection_btn = QtWidgets.QPushButton("Test Connection") + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.reset_btn = QtWidgets.QPushButton("Reset Form") + + btn_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.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + layout.addWidget(btn_box) + + frame_layout.addWidget(btn_box) + + # Connect signals to explicit slots + self.add_btn.clicked.connect(self._add_config) + self.test_connection_btn.clicked.connect(self._test_connection) + self.reset_btn.clicked.connect(self._reset_config) + self.cancel_btn.clicked.connect(self._reject_config) + + # layout.addWidget(self._device_config_template) + self.update_variant_combo(self._control_widgets["group_combo"].currentText()) + self.finished.connect(self._finished) + + # 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]: + """Return the result of the last configuration validation.""" + return self._validation_result + + @config_validation_result.setter + def config_validation_result(self, result: tuple[dict, int, int, str]): + self._validation_result = result + + def set_device_config(self, device_config: dict): + """Set the device configuration in the template form.""" + # Figure out which group and variant this config belongs to + device_class = device_config.get("deviceClass", None) + for group, variants in OPHYD_DEVICE_TEMPLATES.items(): + for variant, template_info in variants.items(): + if template_info.get("deviceClass", None) == device_class: + # Found the matching group and variant + self._control_widgets["group_combo"].setCurrentText(group) + self.update_variant_combo(group) + self._control_widgets["variant_combo"].setCurrentText(variant) + self._device_config_template.set_config_fields(device_config) + return + # If no match found, set to default + self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE) + self.update_variant_combo(DEFAULT_DEVICE) + self._device_config_template.set_config_fields(device_config) + self._old_device_name = device_config.get("name", "") + + def sizeHint(self) -> QtCore.QSize: + return QtCore.QSize(1600, 1000) + + def create_control_panel(self) -> QtWidgets.QGroupBox: + self._control_box = QtWidgets.QGroupBox("Choose a Device Group") + layout = QtWidgets.QGridLayout(self._control_box) + + group_label = QtWidgets.QLabel("Device Group:") + layout.addWidget(group_label, 0, 0) + + group_combo = QtWidgets.QComboBox() + group_combo.addItems(self._group_variants.keys()) + self._control_widgets["group_combo"] = group_combo + layout.addWidget(group_combo, 1, 0) + + variant_label = QtWidgets.QLabel("Variants:") + layout.addWidget(variant_label, 0, 1) + + variant_combo = QtWidgets.QComboBox() + self._control_widgets["variant_combo"] = variant_combo + layout.addWidget(variant_combo, 1, 1) + + group_combo.currentTextChanged.connect(self.update_variant_combo) + variant_combo.currentTextChanged.connect(self.update_device_config_template) + + return self._control_box + + def update_variant_combo(self, group_name: str): + variant_combo = self._control_widgets["variant_combo"] + variant_combo.clear() + variant_combo.addItems(self._group_variants.get(group_name, [])) + if variant_combo.count() <= 1: + variant_combo.setEnabled(False) + else: + variant_combo.setEnabled(True) + + def update_device_config_template(self, variant_name: str): + group_name = self._control_widgets["group_combo"].currentText() + template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {}) + if template_info: + self._device_config_template.change_template(template_info) + else: + self._device_config_template.change_template( + OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE] + ) + + def _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.""" + try: + if ( + DeviceModel.model_validate(device_config) + == DeviceModel.model_validate(self._validation_result[0]) + and connection_status == ConnectionStatus.UNKNOWN.value + ): + # 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. + 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." + ) + 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}", + ) + 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() + + def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox: + msg_box = QtWidgets.QMessageBox(self) + msg_box.setIcon(QtWidgets.QMessageBox.Warning) + msg_box.setWindowTitle(title) + msg_box.setText(text) + return msg_box + + def _test_connection(self): + config = self._device_config_template.get_config_fields() + dialog = DeviceManagerOphydValidationDialog(self, config=config) + result = dialog.exec() + if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected): + self.config_validation_result = dialog.validation_result + + def _reset_config(self): + self._device_config_template.reset_to_defaults() + + def _reject_config(self): + self.reject() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("light") + + dialog = DeviceFormDialog() + dialog.resize(1200, 800) + dialog.show() + sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py new file mode 100644 index 000000000..ec4e7f014 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py @@ -0,0 +1,691 @@ +"""Module for the upload redis dialog in the device manager view.""" + +from __future__ import annotations + +from enum import IntEnum +from functools import partial +from typing import TYPE_CHECKING, List, Tuple + +from bec_lib.logger import bec_logger +from bec_qthemes import apply_theme, material_icon +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) + +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 + + +class DeviceStatusItem(QtWidgets.QWidget): + """Individual device status item widget for the validation display.""" + + def __init__( + self, device_config: dict, config_status: int, connection_status: int, parent=None + ): + super().__init__(parent) + self.device_name = device_config.get("name", "") + self.device_config: dict = device_config + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._transparent_button_style = "background-color: transparent; border: none;" + + # Get validation icons + self.colors = get_accent_colors() + self._icon_size = (20, 20) + self.icons = get_validation_icons(self.colors, self._icon_size) + + self._setup_ui() + self._update_display() + + def _setup_ui(self): + """Setup the UI for the device status item.""" + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + + # Device name label + self.name_label = QtWidgets.QLabel(self.device_name) + self.name_label.setMinimumWidth(150) + layout.addWidget(self.name_label) + layout.addStretch() + + # Config status icon + self.config_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.config_icon_label) + + # Connection status icon + self.connection_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.connection_icon_label) + + def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton: + button = QtWidgets.QPushButton() + button.setFlat(True) + button.setEnabled(False) + button.setStyleSheet(self._transparent_button_style) + button.setFixedSize(icon_size[0], icon_size[1]) + return button + + def _update_display(self): + """Update the visual display based on current status.""" + # Update config status + config_icon = self.icons["config_status"].get(self.config_status.value) + if config_icon: + self.config_icon_label.setIcon(config_icon) + + # Update connection status + connection_icon = self.icons["connection_status"].get(self.connection_status.value) + if connection_icon: + self.connection_icon_label.setIcon(connection_icon) + + def update_status(self, config_status: int, connection_status: int): + """Update the status and refresh display.""" + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._update_display() + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status < other_data.config_status + else: + return self_data.connection_status < other_data.connection_status + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status > other_data.config_status + else: + return self_data.connection_status > other_data.connection_status + return super().__gt__(other) + + +class ValidationSection(QtWidgets.QGroupBox): + """Section widget for displaying validation results.""" + + def __init__(self, title: str, parent=None): + super().__init__(title, parent=parent) + self._setup_ui() + # self.device_items: Dict[str, DeviceStatusItem] = {} + + def _setup_ui(self): + """Setup the UI for the validation section.""" + layout = QtWidgets.QVBoxLayout(self) + + # Status summary label + summary_layout = QtWidgets.QHBoxLayout() + self.summary_icon = QtWidgets.QLabel() + self.summary_icon.setFixedSize(24, 24) + self.summary_label = QtWidgets.QLabel() + self.summary_label.setWordWrap(True) + summary_layout.addWidget(self.summary_icon) + summary_layout.addWidget(self.summary_label) + layout.addLayout(summary_layout) + + # Scroll area for device items + self.table = QtWidgets.QTableWidget() + self.table.setColumnCount(1) + self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.table.horizontalHeader().hide() + self.table.verticalHeader().hide() + self.table.setShowGrid(False) # r + self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder) + layout.addWidget(self.table) + QtCore.QTimer.singleShot(0, self.adjustSize) + + def add_device(self, device_config: dict, config_status: int, connection_status: int): + """ + Add a device to the validation section. + + Args: + device_config (dict): The device configuration dictionary. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.table.setSortingEnabled(False) + device_name = device_config.get("name", "") + row = self._find_row_by_name(device_name) + if row is not None: + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + widget.update_status(config_status, connection_status) + else: + row_position = self.table.rowCount() + self.table.insertRow(row_position) + sort_item = SortTableItem(device_name) + sort_item.setText("") + self.table.setItem(row_position, 0, sort_item) + device_item = DeviceStatusItem(device_config, config_status, connection_status) + sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item) + self.table.setCellWidget(row_position, 0, device_item) + self.table.resizeRowsToContents() + self.table.setSortingEnabled(True) + + def _find_row_by_name(self, device_name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + item: SortTableItem = self.table.item(row, 0) + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + if widget.device_name == device_name: + return row + return None + + def remove_device(self, device_name: str): + """Remove a device from the table by name.""" + self.table.setSortingEnabled(False) + row = self._find_row_by_name(device_name) + if row is not None: + self.table.removeRow(row) + self.table.setSortingEnabled(True) + + def clear_devices(self): + """Clear all device items.""" + self.table.setSortingEnabled(False) + while self.table.rowCount() > 0: + self.table.removeRow(0) + self.table.setSortingEnabled(True) + + def update_summary(self, text: str, icon: QtGui.QPixmap = None): + """Update the summary label.""" + self.summary_label.setText(text) + if icon: + self.summary_icon.setPixmap(icon) + + +class UploadRedisDialog(QtWidgets.QDialog): + """ + Dialog for uploading device configurations to BEC server with validation checks. + """ + + class UploadAction(IntEnum): + """Enum for upload actions.""" + + CANCEL = QtWidgets.QDialog.DialogCode.Rejected + OK = QtWidgets.QDialog.DialogCode.Accepted + CONNECTION_TEST_REQUESTED = 999 + + # Request ophyd validation for all untested device connections + # list of device configs, added: bool, connect: bool + request_ophyd_validation = QtCore.Signal(list, bool, bool) + + def __init__(self, parent, 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._transparent_button_style = "background-color: transparent; border: none;" + + self.colors = get_accent_colors() + self.icons = get_validation_icons(self.colors, (20, 20)) + material_icon_partial = partial(material_icon, size=(24, 24), filled=True) + self._label_icons = { + "success": material_icon_partial("check_circle", color=self.colors.success), + "warning": material_icon_partial("warning", color=self.colors.warning), + "error": material_icon_partial("error", color=self.colors.emergency), + "reload": material_icon_partial("refresh", color=self.colors.default), + "upload": material_icon_partial("cloud_upload", color=self.colors.default), + } + + # Track validation states + self.has_invalid_configs: int = 0 + self.has_untested_connections: int = 0 + self.has_cannot_connect: int = 0 + + self._setup_ui() + self._update_ui() + + def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]): + """ + Update the device configuration in the dialog. + + Args: + device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure + {device_name: (config_dict, config_status, connection_status)}. + """ + self.config_section.clear_devices() + self.device_configs = device_configs + self._update_ui() + + def _setup_ui(self): + """Setup the main UI for the dialog.""" + self.setWindowTitle("Upload Configuration to BEC Server") + self.setModal(True) # Blocks interaction with other parts of the app + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(16) + + # Header + header_label = QtWidgets.QLabel("Review Configuration Before Upload") + header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;") + layout.addWidget(header_label) + + # Description + desc_label = QtWidgets.QLabel( + "Please review the configuration and connection status of all devices before uploading to BEC Server." + ) + desc_label.setWordWrap(True) + desc_label.setStyleSheet("color: #666; margin-bottom: 16px;") + layout.addWidget(desc_label) + + # Config validation section + sections_layout = QtWidgets.QHBoxLayout() + self.config_section = ValidationSection("Configuration Validation") + sections_layout.addWidget(self.config_section) + layout.addLayout(sections_layout) + + # Action buttons section + self._setup_action_buttons(layout) + + # Dialog buttons + self._setup_dialog_buttons(layout) + self.adjustSize() + + def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the action buttons section.""" + action_group = QtWidgets.QGroupBox("Actions") + action_layout = QtWidgets.QVBoxLayout(action_group) + + # Validate connections button + button_layout = QtWidgets.QHBoxLayout() + self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections") + self.validate_connections_btn.setIcon(self._label_icons["reload"]) + self.validate_connections_btn.clicked.connect(self._validate_connections) + button_layout.addWidget(self.validate_connections_btn) + button_layout.addStretch() + button_layout.addSpacing(16) + action_layout.addLayout(button_layout) + + # Status indicator + status_layout = QtWidgets.QHBoxLayout() + self.status_icon = QtWidgets.QPushButton() + self.status_icon.setFlat(True) + self.status_icon.setEnabled(False) + self.status_icon.setStyleSheet(self._transparent_button_style) + self.status_icon.setFixedSize(24, 24) + self.status_label = QtWidgets.QLabel() + self.status_label.setWordWrap(True) + status_layout.addWidget(self.status_icon) + status_layout.addWidget(self.status_label) + action_layout.addLayout(status_layout) + + parent_layout.addWidget(action_group) + + def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the dialog buttons.""" + button_layout = QtWidgets.QHBoxLayout() + + # Cancel button + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_btn) + + button_layout.addStretch() + + # Upload button + self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server") + self.upload_btn.setIcon(self._label_icons["upload"]) + self.upload_btn.clicked.connect(self._handle_upload) + button_layout.addWidget(self.upload_btn) + + parent_layout.addLayout(button_layout) + + def _populate_device_data(self): + """Populate the dialog with device configuration data.""" + if not self.device_configs: + return + + self.has_invalid_configs = 0 + self.has_untested_connections = 0 + self.has_cannot_connect = 0 + + for device_name, (config, config_status, connection_status) in self.device_configs.items(): + # Add to appropriate sections + self.config_section.add_device(config, config_status, connection_status) + + # Track statistics + if config_status == ConfigStatus.INVALID.value: + self.has_invalid_configs += 1 + if connection_status == ConnectionStatus.UNKNOWN.value: + self.has_untested_connections += 1 + if connection_status == ConnectionStatus.CANNOT_CONNECT.value: + self.has_cannot_connect += 1 + + # Update section summaries + num_devices = len(self.device_configs) + + # Config validation summary + if self.has_invalid_configs > 0: + icon = self._label_icons["error"] + text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid." + else: + icon = self._label_icons["success"] + text = f"All {num_devices} device configurations are valid." + if self.has_untested_connections > 0: + icon = self._label_icons["warning"] + text += f"{self.has_untested_connections} device connections are not tested." + if self.has_cannot_connect > 0: + icon = self._label_icons["warning"] + text += f"{self.has_cannot_connect} device connections cannot be established." + self.config_section.update_summary(text, icon) + + def _update_ui(self): + """Update UI state based on validation results.""" + # Update first the device data + self._populate_device_data() + + # Invalid configuration have highest priority, upload disabled + if self.has_invalid_configs: + self.status_icon.setIcon(self._label_icons["error"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_invalid_configs} device configurations are invalid.", + "Please fix configuration errors before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(False) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("Invalid Configurations") + + # Next priority: connections that cannot be established, error but upload is enabled + elif self.has_cannot_connect: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_cannot_connect} connections cannot be established.", + "Please fix connection issues before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # Next priority: untested connections, warning but upload is enabled + elif self.has_untested_connections: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_untested_connections} connections have not been tested.", + "Consider validating connections before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # All good, upload enabled + else: + self.status_icon.setIcon(self._label_icons["success"]) + self.status_label.setText( + "\n".join( + [ + "All device configurations are valid.", + "All connections have been successfully tested.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("All Connections Validated") + + @SafeSlot() + def _validate_connections(self): + """Request validation of all untested connections. This will close the dialog.""" + testable_devices: List[dict] = [] + for _, (config, _, connection_status) in self.device_configs.items(): + if connection_status == ConnectionStatus.UNKNOWN.value: + testable_devices.append(config) + elif connection_status == ConnectionStatus.CANNOT_CONNECT.value: + testable_devices.append(config) + + if len(testable_devices) > 0: + self.request_ophyd_validation.emit(testable_devices, True, True) + self.done(self.UploadAction.CONNECTION_TEST_REQUESTED) + + @SafeSlot() + def _handle_upload(self): + """Handle the upload button click with appropriate confirmations.""" + # First priority: invalid configurations, block upload + if self.has_invalid_configs: + detailed_text = ( + f"There is {self.has_invalid_configs} device with an invalid configuration." + if self.has_invalid_configs == 1 + else f"There are {self.has_invalid_configs} devices with invalid configurations." + ) + text = " ".join( + [detailed_text, "Invalid configuration can not be uploaded to the BEC Server."] + ) + QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text) + self.done(self.UploadAction.CANCEL) + return + + # Next priority: connections that cannot be established, show warning, but allow to proceed + if self.has_cannot_connect: + detailed_text = ( + f"There is {self.has_cannot_connect} device that cannot connect" + if self.has_cannot_connect == 1 + else f"There are {self.has_cannot_connect} devices that cannot connect." + ) + text = " ".join( + [ + detailed_text, + "These devices may not be reachable and disabled BEC upon loading the config.", + "Consider validating these connections before proceeding.\n\n", + "Continue anyway?", + ] + ) + reply = QtWidgets.QMessageBox.critical( + self, + "Devices cannot Connect", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # If some connections are untested, warn the user + if self.has_untested_connections: + detailed_text = ( + f"There is {self.has_untested_connections} device with untested connections." + if self.has_untested_connections == 1 + else f"There are {self.has_untested_connections} devices with untested connections." + ) + text = " ".join( + [ + detailed_text, + "Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.", + ] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Untested Connections", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # Final confirmation + text = " ".join( + ["You are about to upload the device configurations to BEC Server.", "Please confirm."] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Upload to BEC Server", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.Yes, + ) + if reply == QtWidgets.QMessageBox.Yes: + self.done(self.UploadAction.OK) + else: + self.done(self.UploadAction.CANCEL) + + @SafeSlot(dict, int, int, str) + def _update_from_ophyd_device_tests( + self, + device_config: dict, + config_status: int, + connection_status: int, + validation_message: str = "", + ): + """ + Update device status from ophyd device tests. This has to be with a connection_status that was updated. + + """ + if connection_status == ConnectionStatus.UNKNOWN.value: + return + self.update_device_status(device_config, config_status, connection_status) + + @SafeSlot(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) + + +def main(): # pragma: no cover + """Test the upload redis dialog.""" + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # Sample device configurations for testing + sample_configs = [ + ( + {"name": "motor_x", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_1", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_2", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_z", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_x1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_11", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "detector_21", "deviceClass": "EpicsSignal"}, + ConfigStatus.INVALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "motor_z1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ] + configs = {cfg[0]["name"]: cfg for cfg in sample_configs} + apply_theme("dark") + dialog = UploadRedisDialog(parent=None, device_configs=configs) + dialog.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py new file mode 100644 index 000000000..45dfc5eb1 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -0,0 +1,868 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import TYPE_CHECKING, 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.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_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + ConfigChoiceDialog, + DeviceFormDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + UploadRedisDialog, +) +from bec_widgets.utils.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.control.device_manager.components import ( + DeviceTable, + DMConfigView, + DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + 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 + +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + + +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.""" + + RPC = False + + request_ophyd_validation = Signal(list, bool, bool) + + def __init__(self, parent=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() + + # Device Table View widget + self.device_table_view = DeviceTable(self) + + # Device Config View widget + self.dm_config_view = DMConfigView(self) + + # Docstring View + self.dm_docs_view = DocstringView(self) + + # Ophyd Test view + self.ophyd_widget_view = QWidget(self) + layout = QVBoxLayout(self.ophyd_widget_view) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + self.ophyd_test_view = OphydValidation(self, hide_legend=False) + layout.addWidget(self.ophyd_test_view) + + # Validation Results view + self.validation_results = QTextEdit(self) + self.validation_results.setReadOnly(True) + self.validation_results.setPlaceholderText("Validation results will appear here...") + layout.addWidget(self.validation_results) + self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb) + + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.validation_completed, + (self.device_table_view.update_device_validation,), + ), + ( + self.ophyd_test_view.multiple_validations_completed, + (self.device_table_view.update_multiple_device_validations,), + ), + (self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.device_table_config_changed,), + ), + ( + self.device_table_view.device_config_in_sync_with_redis, + (self._update_config_in_sync,), + ), + (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) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _build_docks(self) -> None: + # Central device table + self.device_table_view_dock = self.new( + self.device_table_view, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Bottom area: docstrings + self.dm_docs_view_dock = self.new( + self.dm_docs_view, + where="bottom", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + # Config view left of docstrings + self.dm_config_view_dock = self.new( + self.dm_config_view, + where="left", + relative_to=self.dm_docs_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Right area: ophyd test + validation + self.ophyd_test_dock_view = self.new( + self.ophyd_widget_view, + where="right", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]}) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Load from disk + load = MaterialIconAction( + text_position="under", + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + save_to_disk = MaterialIconAction( + text_position="under", + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", + ) + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") + + # Add flush config in redis + flush_redis = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Flush current config in BEC Server", + label_text="Flush loaded Config", + ) + flush_redis.action.triggered.connect(self._flush_redis_action) + self.toolbar.components.add_safe("flush_redis", flush_redis) + io_bundle.add_action("flush_redis") + + # Add load config from redis + load_redis = MaterialIconAction( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from BEC Server", + label_text="Get loaded Config", + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + text_position="under", + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in BEC Server", + label_text="Update Config", + ) + update_config_redis.action.setEnabled(False) + + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + # Table actions + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config View", + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction( + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", + ) + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", + ) + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._run_validate_connection) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + ###################################### + ### 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): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + configs = list(self.device_table_view.get_selected_device_configs()) + if not configs: + configs = self.device_table_view.get_device_config() + # 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) + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + config_path = self._get_config_base_path() + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_config_base_path(self) -> str: + """Get the base path for device configurations.""" + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + return config_path + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + ALLOWED_EXTS = [".yaml", ".yml"] + filter_str = "YAML files (*.yaml *.yml);;All Files (*)" + initial_filter = "YAML files (*.yaml *.yml);;" + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, + caption="Select Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, + caption="Save Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + if not file_path: + return "" + _, ext = os.path.splitext(file_path) + if ext.lower() not in ALLOWED_EXTS: + file_path += ".yaml" + return file_path + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ + if len(self.device_table_view.get_device_config()) == 0: + # If no config is composed yet, load directly + self.device_table_view.set_device_config(config) + return + dialog = ConfigChoiceDialog(self) + result = dialog.exec() + if result == ConfigChoiceDialog.Result.REPLACE: + self.device_table_view.set_device_config(config) + elif result == ConfigChoiceDialog.Result.ADD: + self.device_table_view.add_device_configs(config) + + @SafeSlot() + def _flush_redis_action(self): + """Action to flush the current config in Redis.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if len(self.client.device_manager.devices) == 0: + logger.info("No devices in BEC Server, nothing to flush.") + QMessageBox.information( + self, "No Devices", "There is currently no config loaded on the BEC Server." + ) + return + reply = _yes_no_question( + self, + "Flush BEC Server Config", + "Do you really want to flush the current config in BEC Server?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.client.config.reset_config() + logger.info("Successfully flushed configuration in BEC Server.") + # Check if config is in sync, enable load redis button + self.device_table_view.device_config_in_sync_with_redis.emit( + self.device_table_view._is_config_in_sync_with_redis() + ) + validation_results = self.device_table_view.get_validation_results() + for config, config_status, connnection_status in validation_results.values(): + if connnection_status == ConnectionStatus.CONNECTED.value: + self.device_table_view.update_device_validation( + config, config_status, ConnectionStatus.CAN_CONNECT, "" + ) + + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if not self.device_table_view.get_device_config(): + # If no config is composed yet, load directly + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + return + reply = _yes_no_question( + self, + "Load currently active config in BEC Server", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """Action to push the current composition to Redis using the upload dialog.""" + # Check if validations are still running + if self.ophyd_test_view.running_ophyd_tests is True: + return QMessageBox.warning( + self, "Validation in Progress", "Please wait for the validation to finish." + ) + + # Get all device configurations with their validation status + validation_results = self.device_table_view.get_validation_results() + # Create and show upload dialog + self._upload_redis_dialog = UploadRedisDialog( + parent=self, device_configs=validation_results + ) + self._upload_redis_dialog.request_ophyd_validation.connect( + self.request_ophyd_validation.emit + ) + + # Show dialog + reply = self._upload_redis_dialog.exec_() + + if reply == UploadRedisDialog.UploadAction.OK: + self._push_composition_to_redis(action="set") + elif reply == UploadRedisDialog.UploadAction.CANCEL: + self.ophyd_test_view.cancel_all_validations() + 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.""" + if action not in get_args(ConfigAction): + logger.error(f"Invalid config action: {action} for uploading to BEC Server.") + return + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, action) + comm.signals.done.connect(self._handle_push_complete_to_communicator) + comm.signals.error.connect(self._handle_exception_from_communicator) + threadpool.start(comm) + self._set_busy_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) + + def _handle_push_complete_to_communicator(self): + """Handle completion of the config push to Redis.""" + self._set_busy_wrapper(enabled=False) + + def _handle_exception_from_communicator(self, exception: Exception): + """Handle exceptions from the config communicator.""" + QMessageBox.critical( + self, + "Error Uploading Config", + f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", + ) + self._set_busy_wrapper(enabled=False) + + @SafeSlot() + def _save_to_disk_action(self): + """Action for the 'save_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path = self._get_file_path(config_path, "save_file") + if file_path: + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + if os.path.exists(file_path): + reply = _yes_no_question( + self, + "Overwrite File", + f"The file '{file_path}' already exists. Do you want to overwrite it?", + ) + if reply != QMessageBox.StandardButton.Yes: + return + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # Table actions + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + @SafeSlot(dict) + def _edit_device_action(self, device_config: dict): + """Action to edit a selected device configuration.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes") + dialog.accepted_data.connect(self._update_device_to_table_from_dialog) + dialog.set_device_config(device_config) + dialog.open() + + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device") + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict, int, int, str, str) + def _update_device_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + if old_device_name and old_device_name != data.get("name", ""): + self.device_table_view.remove_device(old_device_name) + self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name) + + @SafeSlot(dict, int, int, str, str) + def _add_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + 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 + ) + + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + configs = self.device_table_view.get_selected_device_configs() + if not configs: + QMessageBox.warning( + self, "No devices selected", "Please select devices from the table to remove." + ) + return + if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.device_table_view.remove_device_configs(configs) + + @SafeSlot(dict, int, int, str, str) + def _ophyd_test_item_clicked_cb( + self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str + ) -> None: + self.validation_results.setMarkdown(md_msg) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerDisplayWidget() + l.addWidget(device_manager_view) + w.show() + w.setWindowTitle("Device Manager View") + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + w.resize(width, height) + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py new file mode 100644 index 000000000..24e80688b --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -0,0 +1,73 @@ +"""Module for Device Manager View.""" + +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, +) +from bec_widgets.applications.views.view import ViewBase +from bec_widgets.utils.error_popups import SafeSlot + + +class DeviceManagerView(ViewBase): + """ + A view for users to manage devices within the application. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.device_manager_widget = DeviceManagerWidget(parent=self) + self.set_content(self.device_manager_widget) + + @SafeSlot() + def on_enter(self) -> None: + """Called after the view becomes current/visible. + + Default implementation does nothing. Override in subclasses. + """ + self.device_manager_widget.on_enter() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + device_manager_view = DeviceManagerView() + _app.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=device_manager_view.device_manager_widget, + mini_text="DM", + ) + _app.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py new file mode 100644 index 000000000..4129cd96f --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -0,0 +1,112 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +import os + +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + DeviceManagerDisplayWidget, +) +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + + RPC = False + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + self.setLayout(self.stacked_layout) + + # Add device manager view + self.device_manager_display = DeviceManagerDisplayWidget(parent=self, client=self.client) + self.stacked_layout.addWidget(self.device_manager_display) + + # Add overlay widget + self._overlay_widget = QtWidgets.QWidget(self) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self._initialized = False + + def on_enter(self) -> None: + """Called after the widget becomes visible.""" + if self._initialized is False: + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_overlay(self): + self._overlay_widget.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + # Load current config + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + # Load config from disk + self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File") + icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False) + self.button_load_config_from_file.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_config_from_file) + self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked) + self._overlay_widget.setVisible(True) + + def _load_config_from_file_clicked(self): + """Handle click on 'Load Config From File' button.""" + self.device_manager_display._load_file_action() + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_display.device_table_view.set_device_config(config) + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + from bec_widgets.utils.colors import apply_theme + + apply_theme("light") + + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + device_manager = DeviceManagerWidget() + # config = device_manager.client.device_manager._get_redis_device_config() + # device_manager.device_table_view.set_device_config(config) + layout.addWidget(device_manager) + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + dark_mode_button = DarkModeButton() + layout.addWidget(dark_mode_button) + widget.show() + device_manager.setWindowTitle("Device Manager View") + device_manager.resize(1600, 1200) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/dock_area_view/__init__.py b/bec_widgets/applications/views/dock_area_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/dock_area_view/dock_area_view.py b/bec_widgets/applications/views/dock_area_view/dock_area_view.py new file mode 100644 index 000000000..322a60738 --- /dev/null +++ b/bec_widgets/applications/views/dock_area_view/dock_area_view.py @@ -0,0 +1,24 @@ +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. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.dock_area = BECDockArea( + self, profile_namespace="bec", auto_profile_namespace=False, object_name="DockArea" + ) + self.set_content(self.dock_area) diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py new file mode 100644 index 000000000..3b98f7568 --- /dev/null +++ b/bec_widgets/applications/views/view.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from qtpy.QtCore import QEventLoop +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QStackedLayout, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.plots.waveform.waveform import Waveform + + +class ViewBase(QWidget): + """Wrapper for a content widget used inside the main app's stacked view. + + Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden. + + Args: + content (QWidget): The actual view widget to display. + parent (QWidget | None): Parent widget. + id (str | None): Optional view id, useful for debugging or introspection. + title (str | None): Optional human-readable title. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent) + self.content: QWidget | None = None + self.view_id = id + self.view_title = title + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + if content is not None: + self.set_content(content) + + def set_content(self, content: QWidget) -> None: + """Replace the current content widget with a new one.""" + if self.content is not None: + self.content.setParent(None) + self.content = content + self.layout().addWidget(content) + + @SafeSlot() + def on_enter(self) -> None: + """Called after the view becomes current/visible. + + Default implementation does nothing. Override in subclasses. + """ + pass + + @SafeSlot() + def on_exit(self) -> bool: + """Called before the view is switched away/hidden. + + Return True to allow switching, or False to veto. + Default implementation allows switching. + """ + return True + + +#################################################################################################### +# Example views for demonstration/testing purposes +#################################################################################################### + + +# --- Popup UI version --- +class WaveformViewPopup(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + self.waveform = Waveform(parent=self) + self.set_content(self.waveform) + + @SafeSlot() + def on_enter(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("Configure Waveform View") + + label = QLabel("Select device and signal for the waveform plot:", parent=dialog) + + # same as in the CurveRow used in waveform + self.device_edit = DeviceComboBox(parent=self) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + self.entry_edit = SignalComboBox(parent=self) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(label) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + v = QVBoxLayout(dialog) + v.addLayout(form) + v.addWidget(buttons) + + if dialog.exec_() == QDialog.Accepted: + self.waveform.plot( + y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText() + ) + + @SafeSlot() + def on_exit(self) -> bool: + ans = QMessageBox.question( + self, + "Switch and clear?", + "Do you want to switch views and clear the plot?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans == QMessageBox.Yes: + self.waveform.clear_all() + return True + return False + + +# --- Inline stacked UI version --- +class WaveformViewInline(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # Root layout for this view uses a stacked layout + self.stack = QStackedLayout() + container = QWidget(self) + container.setLayout(self.stack) + self.set_content(container) + + # --- Page 0: Settings page (inline form) + self.settings_page = QWidget() + sp_layout = QVBoxLayout(self.settings_page) + sp_layout.setContentsMargins(16, 16, 16, 16) + sp_layout.setSpacing(12) + + title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page) + self.device_edit = DeviceComboBox(parent=self.settings_page) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + + self.entry_edit = SignalComboBox(parent=self.settings_page) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(title) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", parent=self.settings_page) + cancel_btn = QPushButton("Cancel", parent=self.settings_page) + btn_row.addStretch(1) + btn_row.addWidget(cancel_btn) + btn_row.addWidget(ok_btn) + + sp_layout.addLayout(form) + sp_layout.addLayout(btn_row) + + # --- Page 1: Waveform page + self.waveform_page = QWidget() + wf_layout = QVBoxLayout(self.waveform_page) + wf_layout.setContentsMargins(0, 0, 0, 0) + self.waveform = Waveform(parent=self.waveform_page) + wf_layout.addWidget(self.waveform) + + # --- Page 2: Exit confirmation page (inline) + self.confirm_page = QWidget() + cp_layout = QVBoxLayout(self.confirm_page) + cp_layout.setContentsMargins(16, 16, 16, 16) + cp_layout.setSpacing(12) + qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page) + cp_buttons = QHBoxLayout() + no_btn = QPushButton("No", parent=self.confirm_page) + yes_btn = QPushButton("Yes", parent=self.confirm_page) + cp_buttons.addStretch(1) + cp_buttons.addWidget(no_btn) + cp_buttons.addWidget(yes_btn) + cp_layout.addWidget(qlabel) + cp_layout.addLayout(cp_buttons) + + # Add pages to the stack + self.stack.addWidget(self.settings_page) # index 0 + self.stack.addWidget(self.waveform_page) # index 1 + self.stack.addWidget(self.confirm_page) # index 2 + + # Wire settings buttons + ok_btn.clicked.connect(self._apply_settings_and_show_waveform) + cancel_btn.clicked.connect(self._show_waveform_without_changes) + + # Prepare result holder for the inline confirmation + self._exit_choice_yes = None + yes_btn.clicked.connect(lambda: self._exit_reply(True)) + no_btn.clicked.connect(lambda: self._exit_reply(False)) + + @SafeSlot() + def on_enter(self) -> None: + # Always start on the settings page when entering + self.stack.setCurrentIndex(0) + + @SafeSlot() + def on_exit(self) -> bool: + # Show inline confirmation page and synchronously wait for a choice + # -> trick to make the choice blocking, however popup would be cleaner solution + self._exit_choice_yes = None + self.stack.setCurrentIndex(2) + loop = QEventLoop() + self._exit_loop = loop + loop.exec_() + + if self._exit_choice_yes: + self.waveform.clear_all() + return True + # Revert to waveform view if user cancelled switching + self.stack.setCurrentIndex(1) + return False + + def _apply_settings_and_show_waveform(self): + dev = self.device_edit.currentText() + sig = self.entry_edit.currentText() + if dev and sig: + self.waveform.plot(y_name=dev, y_entry=sig) + self.stack.setCurrentIndex(1) + + def _show_waveform_without_changes(self): + # Just show waveform page without plotting + self.stack.setCurrentIndex(1) + + def _exit_reply(self, yes: bool): + self._exit_choice_yes = bool(yes) + if hasattr(self, "_exit_loop") and self._exit_loop.isRunning(): + self._exit_loop.quit() diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index c163a9084..cf6eb1726 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -27,17 +27,14 @@ class _WidgetsEnumType(str, enum.Enum): _Widgets = { - "AbortButton": "AbortButton", - "BECDockArea": "BECDockArea", "BECMainWindow": "BECMainWindow", "BECProgressBar": "BECProgressBar", "BECQueue": "BECQueue", + "BECShell": "BECShell", "BECStatusBox": "BECStatusBox", "DapComboBox": "DapComboBox", "DarkModeButton": "DarkModeButton", "DeviceBrowser": "DeviceBrowser", - "DeviceComboBox": "DeviceComboBox", - "DeviceLineEdit": "DeviceLineEdit", "Heatmap": "Heatmap", "Image": "Image", "LogPanel": "LogPanel", @@ -51,19 +48,14 @@ class _WidgetsEnumType(str, enum.Enum): "PositionerBox2D": "PositionerBox2D", "PositionerControlLine": "PositionerControlLine", "PositionerGroup": "PositionerGroup", - "ResetButton": "ResetButton", "ResumeButton": "ResumeButton", "RingProgressBar": "RingProgressBar", "SBBMonitor": "SBBMonitor", "ScanControl": "ScanControl", "ScanProgressBar": "ScanProgressBar", "ScatterWaveform": "ScatterWaveform", - "SignalComboBox": "SignalComboBox", "SignalLabel": "SignalLabel", - "SignalLineEdit": "SignalLineEdit", - "StopButton": "StopButton", "TextBox": "TextBox", - "VSCodeEditor": "VSCodeEditor", "Waveform": "Waveform", "WebConsole": "WebConsole", "WebsiteWidget": "WebsiteWidget", @@ -98,16 +90,6 @@ class _WidgetsEnumType(str, enum.Enum): logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}") -class AbortButton(RPCBase): - """A button that abort the scan.""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - class AutoUpdates(RPCBase): @property @rpc_call @@ -144,303 +126,273 @@ def selected_device(self) -> "str | None": """ -class BECDock(RPCBase): - @property +class AvailableDeviceResources(RPCBase): @rpc_call - def _config_dict(self) -> "dict": + def remove(self): """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. + Cleanup the BECConnector """ - @property @rpc_call - def element_list(self) -> "list[BECWidget]": + def attach(self): """ - Get the widgets in the dock. - - Returns: - widgets(list): The widgets in the dock. + None """ - @property @rpc_call - def elements(self) -> "dict[str, BECWidget]": + def detach(self): """ - Get the widgets in the dock. - - Returns: - widgets(dict): The widgets in the dock. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + +class BECDockArea(RPCBase): @rpc_call 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. + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + 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": + """ + Create a new widget (or reuse an instance) and add it as a 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. - """ - - @rpc_call - def show(self): - """ - Show the dock. - """ - - @rpc_call - def hide(self): - """ - Hide the dock. - """ + 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 `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). + 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. - @rpc_call - def show_title_bar(self): - """ - Hide the title bar of the dock. + Returns: + BECWidget: The created or reused widget instance. """ @rpc_call - def set_title(self, title: "str"): + def widget_map(self) -> "dict[str, QWidget]": """ - Set the title of the dock. - - Args: - title(str): The title of the dock. + Return a dictionary mapping widget names to their corresponding widgets. """ @rpc_call - def hide_title_bar(self): + def widget_list(self) -> "list[QWidget]": """ - Hide the title bar of the dock. + Return a list of all widgets contained in the dock area. """ + @property @rpc_call - def available_widgets(self) -> "list": + def workspace_is_locked(self) -> "bool": """ - List all widgets that can be added to the dock. + Get or set the lock state of the workspace. Returns: - list: The list of eligible widgets. + bool: True if the workspace is locked, False otherwise. """ @rpc_call - def delete(self, widget_name: "str") -> "None": + def attach_all(self): """ - Remove a widget from the dock. - - Args: - widget_name(str): Delete the widget with the given name. + Re-attach floating docks back into the dock manager. """ @rpc_call def delete_all(self): """ - Remove all widgets from the dock. - """ - - @rpc_call - def remove(self): - """ - Remove the dock from the parent dock area. + Delete all docks and their associated widgets. """ @rpc_call - def attach(self): - """ - Attach the dock to the parent dock area. - """ - - @rpc_call - def detach(self): - """ - Detach the dock from the parent dock area. - """ - - -class BECDockArea(RPCBase): - """Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.""" - - @property - @rpc_call - def _rpc_id(self) -> "str": - """ - Get the RPC ID of the widget. + def delete(self, object_name: "str") -> "bool": """ + Remove a widget from the dock area by its object name. - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. + Args: + object_name: The object name of the widget to remove. Returns: - dict: The configuration of the widget. - """ + bool: True if the widget was found and removed, False otherwise. - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.delete("my_widget") + True """ @rpc_call - def new( + def set_layout_ratios( 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": + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": """ - Add a dock to the dock area. Dock has QGridLayout as layout manager by default. + Adjust splitter ratios in the dock layout. 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. + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) """ @rpc_call - def show(self): + def describe_layout(self) -> "list[dict[str, Any]]": """ - Show all windows including floating docks. + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. """ + @property @rpc_call - def hide(self): + def mode(self) -> "str": """ - Hide all windows including floating docks. + None """ - @property + @mode.setter @rpc_call - def panels(self) -> "dict[str, BECDock]": + def mode(self) -> "str": """ - Get the docks in the dock area. - Returns: - dock_dict(dict): The docks in the dock area. + None """ - @property @rpc_call - def panel_list(self) -> "list[BECDock]": + def list_profiles(self) -> "list[str]": """ - Get the docks in the dock area. + List available workspace profiles in the current namespace. Returns: - list: The docks in the dock area. + list[str]: List of profile names. """ + @rpc_timeout(None) @rpc_call - def delete(self, dock_name: "str"): + def save_profile( + self, + name: "str | None" = None, + *, + show_dialog: "bool" = False, + quick_select: "bool | None" = None, + ): """ - Delete a dock by name. + Save the current workspace profile. + + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + Read-only bundled profiles cannot be overwritten. Args: - dock_name(str): The name of the dock to delete. + 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. """ + @rpc_timeout(None) @rpc_call - def delete_all(self) -> "None": - """ - Delete all docks. + def load_profile(self, name: "str | None" = None, start_empty: "bool" = False): """ + Load a workspace profile. - @rpc_call - 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. + 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. + start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. """ @rpc_call - def detach_dock(self, dock_name: "str") -> "BECDock": + def delete_profile(self, name: "str | None" = None, show_dialog: "bool" = False) -> "bool": """ - Undock a dock from the dock area. + Delete a workspace profile. Args: - dock_name(str): The dock to undock. + 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: - BECDock: The undocked dock. - """ - - @rpc_call - def attach_all(self): - """ - Return all floating docks to the dock area. - """ + bool: True if the profile was deleted, False otherwise. - @rpc_call - def save_state(self) -> "dict": + Raises: + ValueError: If the profile is read-only or doesn't exist (when show_dialog=False). """ - Save the state of the dock area. - Returns: - dict: The state of the dock area. - """ - @rpc_timeout(None) +class BECMainWindow(RPCBase): @rpc_call - def screenshot(self, file_name: "str | None" = None): + def remove(self): """ - Take a screenshot of the dock area and save it to a file. + Cleanup the BECConnector """ @rpc_call - def restore_state( - self, state: "dict" = None, missing: "Literal['ignore', 'error']" = "ignore", extra="bottom" - ): + def attach(self): """ - 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. + None """ - -class BECMainWindow(RPCBase): @rpc_call - def remove(self): + def detach(self): """ - Cleanup the BECConnector + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @@ -526,6 +478,40 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + +class BECShell(RPCBase): + """A WebConsole pre-configured to run the BEC shell.""" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class BECStatusBox(RPCBase): """An autonomous widget to display the status of BEC services.""" @@ -542,6 +528,25 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class BaseROI(RPCBase): """Base class for all Region of Interest (ROI) implementations.""" @@ -1003,6 +1008,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceBrowser(RPCBase): """DeviceBrowser is a widget that displays all available devices in the current BEC session.""" @@ -1013,27 +1030,38 @@ def remove(self): Cleanup the BECConnector """ - -class DeviceComboBox(RPCBase): - """Combobox widget for device input with autocomplete for device names.""" + @rpc_call + def attach(self): + """ + None + """ @rpc_call - def set_device(self, device: "str"): + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ - Set the device. - Args: - device (str): Default name. + +class DeviceInitializationProgressBar(RPCBase): + """A progress bar that displays the progress of device initialization.""" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector """ - @property @rpc_call - def devices(self) -> "list[str]": + def attach(self): + """ + None """ - Get the list of devices for the applied filters. - Returns: - list[str]: List of devices. + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @@ -1046,37 +1074,194 @@ def remove(self): Cleanup the BECConnector """ - -class DeviceLineEdit(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" + @rpc_call + def attach(self): + """ + None + """ @rpc_call - def set_device(self, device: "str"): + def detach(self): """ - Set the device. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + +class DockAreaWidget(RPCBase): + """Lightweight dock area that exposes the core Qt ADS docking helpers without any""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, + 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" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + object_name: "str | None" = None, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. Args: - device (str): Default name. + 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. + floating_state(Mapping | None): Optional floating geometry metadata to apply when 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). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + 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. + return_dock(bool): When True, return the created dock instead of the widget. + 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 `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``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + 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. """ - @property @rpc_call - def devices(self) -> "list[str]": + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. """ - Get the list of devices for the applied filters. - Returns: - list[str]: List of devices. + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. """ - @property @rpc_call - def _is_valid_input(self) -> bool: + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. """ - Check if the current value is a valid device name. + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + 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 current value is a valid device name, False otherwise. + 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 + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. """ @@ -1211,6 +1396,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -1784,23 +1981,107 @@ def plot( reload: "bool" = False, ): """ - Plot the heatmap with the given x, y, and z data. + Plot the heatmap with the given x, y, and z data. + + Args: + x_name (str): The name of the x-axis signal. + y_name (str): The name of the y-axis signal. + z_name (str): The name of the z-axis signal. + x_entry (str | None): The entry for the x-axis signal. + y_entry (str | None): The entry for the y-axis signal. + z_entry (str | None): The entry for the z-axis signal. + color_map (str | None): The color map to use for the heatmap. + validate_bec (bool): Whether to validate the entries against BEC signals. + interpolation (Literal["linear", "nearest"] | None): The interpolation method to use. + enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans. + oversampling_factor (float | None): Factor to oversample the grid resolution. + lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image. + show_config_label (bool | None): Whether to show the configuration label in the heatmap. + reload (bool): Whether to reload the heatmap with new data. + """ + + @property + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @x_device_name.setter + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @property + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @x_device_entry.setter + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @property + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @y_device_name.setter + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @property + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @y_device_entry.setter + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @property + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @z_device_name.setter + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @property + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ - Args: - x_name (str): The name of the x-axis signal. - y_name (str): The name of the y-axis signal. - z_name (str): The name of the z-axis signal. - x_entry (str | None): The entry for the x-axis signal. - y_entry (str | None): The entry for the y-axis signal. - z_entry (str | None): The entry for the z-axis signal. - color_map (str | None): The color map to use for the heatmap. - validate_bec (bool): Whether to validate the entries against BEC signals. - interpolation (Literal["linear", "nearest"] | None): The interpolation method to use. - enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans. - oversampling_factor (float | None): Factor to oversample the grid resolution. - lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image. - show_config_label (bool | None): Whether to show the configuration label in the heatmap. - reload (bool): Whether to reload the heatmap with new data. + @z_device_entry.setter + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. """ @@ -1813,6 +2094,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -2208,16 +2501,30 @@ def autorange_mode(self) -> "str": @property @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - @monitor.setter + @device_name.setter @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": + """ + The name of the device to monitor for image data. + """ + + @property + @rpc_call + def device_entry(self) -> "str": + """ + The signal/entry name to monitor on the device. """ - The name of the monitor to use for the image. + + @device_entry.setter + @rpc_call + def device_entry(self) -> "str": + """ + The signal/entry name to monitor on the device. """ @rpc_call @@ -2323,8 +2630,8 @@ def transpose(self) -> "bool": @rpc_call def image( self, - monitor: "str | tuple | None" = None, - monitor_type: "Literal['auto', '1d', '2d']" = "auto", + device_name: "str | None" = None, + device_entry: "str | None" = None, color_map: "str | None" = None, color_bar: "Literal['simple', 'full'] | None" = None, vrange: "tuple[int, int] | None" = None, @@ -2333,14 +2640,14 @@ def image( Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ @property @@ -2541,6 +2848,20 @@ def get_data(self) -> "np.ndarray": """ +class LaunchWindow(RPCBase): + @rpc_call + def show_launcher(self): + """ + Show the launcher window. + """ + + @rpc_call + def hide_launcher(self): + """ + Hide the launcher window. + """ + + class LogPanel(RPCBase): """Displays a log panel""" @@ -2566,26 +2887,210 @@ def set_html_text(self, text: str) -> None: class Minesweeper(RPCBase): ... +class MonacoDock(RPCBase): + """MonacoDock is a dock widget that contains Monaco editor instances.""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, + 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" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + object_name: "str | None" = None, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. + + 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. + floating_state(Mapping | None): Optional floating geometry metadata to apply when 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). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + 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. + return_dock(bool): When True, return the created dock instead of the widget. + 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 `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``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + 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. + """ + + @rpc_call + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + 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 + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + + class MonacoWidget(RPCBase): """A simple Monaco editor widget""" @rpc_call - def set_text(self, text: str) -> None: + def set_text( + self, text: "str", file_name: "str | None" = None, reset: "bool" = False + ) -> "None": """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ @rpc_call - def get_text(self) -> str: + def get_text(self) -> "str": """ Get the current text from the Monaco editor. """ @rpc_call - def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: + def insert_text( + self, text: "str", line: "int | None" = None, column: "int | None" = None + ) -> "None": """ Insert text at the current cursor position or at a specified line and column. @@ -2596,7 +3101,7 @@ def insert_text(self, text: str, line: int | None = None, column: int | None = N """ @rpc_call - def delete_line(self, line: int | None = None) -> None: + def delete_line(self, line: "int | None" = None) -> "None": """ Delete a line in the Monaco editor. @@ -2605,7 +3110,16 @@ def delete_line(self, line: int | None = None) -> None: """ @rpc_call - def set_language(self, language: str) -> None: + def open_file(self, file_name: "str") -> "None": + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + @rpc_call + def set_language(self, language: "str") -> "None": """ Set the programming language for syntax highlighting in the Monaco editor. @@ -2614,13 +3128,13 @@ def set_language(self, language: str) -> None: """ @rpc_call - def get_language(self) -> str: + def get_language(self) -> "str": """ Get the current programming language set in the Monaco editor. """ @rpc_call - def set_theme(self, theme: str) -> None: + def set_theme(self, theme: "str") -> "None": """ Set the theme for the Monaco editor. @@ -2629,13 +3143,13 @@ def set_theme(self, theme: str) -> None: """ @rpc_call - def get_theme(self) -> str: + def get_theme(self) -> "str": """ Get the current theme of the Monaco editor. """ @rpc_call - def set_readonly(self, read_only: bool) -> None: + def set_readonly(self, read_only: "bool") -> "None": """ Set the Monaco editor to read-only mode. @@ -2646,10 +3160,10 @@ def set_readonly(self, read_only: bool) -> None: @rpc_call def set_cursor( self, - line: int, - column: int = 1, - move_to_position: Literal[None, "center", "top", "position"] = None, - ) -> None: + line: "int", + column: "int" = 1, + move_to_position: "Literal[None, 'center', 'top', 'position']" = None, + ) -> "None": """ Set the cursor position in the Monaco editor. @@ -2660,7 +3174,7 @@ def set_cursor( """ @rpc_call - def current_cursor(self) -> dict[str, int]: + def current_cursor(self) -> "dict[str, int]": """ Get the current cursor position in the Monaco editor. @@ -2669,7 +3183,7 @@ def current_cursor(self) -> dict[str, int]: """ @rpc_call - def set_minimap_enabled(self, enabled: bool) -> None: + def set_minimap_enabled(self, enabled: "bool") -> "None": """ Enable or disable the minimap in the Monaco editor. @@ -2678,7 +3192,7 @@ def set_minimap_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_vim_mode_enabled(self, enabled: bool) -> None: + def set_vim_mode_enabled(self, enabled: "bool") -> "None": """ Enable or disable Vim mode in the Monaco editor. @@ -2687,7 +3201,7 @@ def set_vim_mode_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_lsp_header(self, header: str) -> None: + def set_lsp_header(self, header: "str") -> "None": """ Set the LSP (Language Server Protocol) header for the Monaco editor. The header is used to provide context for language servers but is not displayed in the editor. @@ -2697,7 +3211,7 @@ def set_lsp_header(self, header: str) -> None: """ @rpc_call - def get_lsp_header(self) -> str: + def get_lsp_header(self) -> "str": """ Get the current LSP header set in the Monaco editor. @@ -2705,6 +3219,25 @@ def get_lsp_header(self) -> str: str: The LSP header. """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class MotorMap(RPCBase): """Motor map widget for plotting motor positions in 2D including a trace of the last points.""" @@ -2715,6 +3248,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -3107,7 +3652,9 @@ def scatter_size(self) -> "int": """ @rpc_call - def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None": + def map( + self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False + ) -> "None": """ Set the x and y motor names. @@ -3115,6 +3662,7 @@ def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "Non x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ @rpc_call @@ -3132,6 +3680,34 @@ def get_data(self) -> "dict": dict: Data of the motor map. """ + @property + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @x_motor.setter + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @property + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + + @y_motor.setter + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + class MultiWaveform(RPCBase): """MultiWaveform widget for displaying multiple waveforms emitted by a single signal.""" @@ -3142,6 +3718,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -3788,6 +4376,18 @@ def set_positioner(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3817,6 +4417,18 @@ def set_positioner_ver(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3865,6 +4477,18 @@ def set_positioner(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3884,6 +4508,25 @@ def set_positioners(self, device_names: "str"): Device names must be separated by space """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class RectangularROI(RPCBase): """Defines a rectangular Region of Interest (ROI) with additional functionality.""" @@ -4014,16 +4657,6 @@ def set_position(self, x: "float", y: "float"): """ -class ResetButton(RPCBase): - """A button that resets the scan queue.""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - class ResumeButton(RPCBase): """A button that continue scan queue.""" @@ -4033,31 +4666,20 @@ def remove(self): Cleanup the BECConnector """ - -class Ring(RPCBase): - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. - """ - - @property @rpc_call - def _rpc_id(self) -> "str": + def attach(self): """ - Get the RPC ID of the widget. + None """ - @property @rpc_call - def _config_dict(self) -> "dict": + def detach(self): """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + +class Ring(RPCBase): @rpc_call def set_value(self, value: "int | float"): """ @@ -4077,14 +4699,24 @@ def set_color(self, color: "str | tuple"): """ @rpc_call - def set_background(self, color: "str | tuple"): + def set_background(self, color: "str | tuple | QColor"): """ - Set the background color for the ring widget + Set the background color for the ring widget. The background color is only used when colors are not linked. Args: color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ + @rpc_call + def set_colors_linked(self, linked: "bool"): + """ + Set whether the colors are linked for the ring widget. + If colors are linked, changing the main color will also change the background color. + + Args: + linked(bool): Whether to link the colors for the ring widget + """ + @rpc_call def set_line_width(self, width: "int"): """ @@ -4107,14 +4739,16 @@ def set_min_max_values(self, min_value: "int | float", max_value: "int | float") @rpc_call def set_start_angle(self, start_angle: "int"): """ - Set the start angle for the ring widget + Set the start angle for the ring widget. Args: start_angle(int): Start angle for the ring widget in degrees """ @rpc_call - def set_update(self, mode: "Literal['manual', 'scan', 'device']", device: "str" = None): + def set_update( + self, mode: "Literal['manual', 'scan', 'device']", device: "str" = "", signal: "str" = "" + ): """ Set the update mode for the ring widget. Modes: @@ -4125,204 +4759,127 @@ def set_update(self, mode: "Literal['manual', 'scan', 'device']", device: "str" Args: mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device" device(str): Device name for the device readback mode, only used when mode is "device" + signal(str): Signal name for the device readback mode, only used when mode is "device" """ @rpc_call - def reset_connection(self): + def set_precision(self, precision: "int"): """ - Reset the connections for the ring widget. Disconnect the current slot and endpoint. + Set the precision for the ring widget. + + Args: + precision(int): Precision for the ring widget """ class RingProgressBar(RPCBase): - """Show the progress of devices, scans or custom values in the form of ring progress bars.""" - @rpc_call - def _get_all_rpc(self) -> "dict": + def remove(self): """ - Get all registered RPC objects. + Cleanup the BECConnector """ - @property @rpc_call - def _rpc_id(self) -> "str": + def attach(self): """ - Get the RPC ID of the widget. + None """ - @property @rpc_call - def _config_dict(self) -> "dict": + def detach(self): """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ - @property + @rpc_timeout(None) @rpc_call - def rings(self) -> "list[Ring]": + def screenshot(self, file_name: "str | None" = None): """ - Returns a list of all rings in the progress bar. + Take a screenshot of the dock area and save it to a file. """ + @property @rpc_call - def update_config(self, config: "RingProgressBarConfig | dict"): + def rings(self) -> list[bec_widgets.widgets.progress.ring_progress_bar.ring.Ring]: """ - Update the configuration of the widget. - - Args: - config(SpiralProgressBarConfig|dict): Configuration to update. + None """ @rpc_call - def add_ring(self, **kwargs) -> "Ring": + def add_ring( + self, config: dict | None = None + ) -> bec_widgets.widgets.progress.ring_progress_bar.ring.Ring: """ - Add a new progress bar. + Add a new ring to the ring progress bar. + Optionally, a configuration dictionary can be provided but the ring + can also be configured later. The config dictionary must provide + the qproperties of the Qt Ring object. Args: - **kwargs: Keyword arguments for the new progress bar. + config(dict | None): Optional configuration dictionary for the ring. Returns: - Ring: Ring object. - """ - - @rpc_call - def remove_ring(self, index: "int"): - """ - Remove a progress bar by index. - - Args: - index(int): Index of the progress bar to remove. - """ - - @rpc_call - def set_precision(self, precision: "int", bar_index: "int | None" = None): - """ - Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars. - - Args: - precision(int): Precision for the progress bars. - bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set. - """ - - @rpc_call - def set_min_max_values( - self, - min_values: "int | float | list[int | float]", - max_values: "int | float | list[int | float]", - ): - """ - Set the minimum and maximum values for the progress bars. - - Args: - min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar. - max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar. + Ring: The newly added ring object. """ @rpc_call - def set_number_of_bars(self, num_bars: "int"): + def remove_ring(self, index: int | None = None): """ - Set the number of progress bars to display. - + Remove a ring from the ring progress bar. Args: - num_bars(int): Number of progress bars to display. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ @rpc_call - def set_value(self, values: "int | list", ring_index: "int" = None): + def set_gap(self, value: int): """ - Set the values for the progress bars. + Set the gap between rings. Args: - values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar. - ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set. - - Examples: - >>> SpiralProgressBar.set_value(50) - >>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner) - >>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar. + value(int): Gap value in pixels. """ @rpc_call - def set_colors_from_map(self, colormap, color_format: "Literal['RGB', 'HEX']" = "RGB"): + def set_center_label(self, text: str): """ - Set the colors for the progress bars from a colormap. + Set the center label text. Args: - colormap(str): Name of the colormap. - color_format(Literal["RGB","HEX"]): Format of the returned colors ('RGB', 'HEX'). - """ - - @rpc_call - def set_colors_directly( - self, colors: "list[str | tuple] | str | tuple", bar_index: "int" = None - ): + text(str): Text for the center label. """ - Set the colors for the progress bars directly. - Args: - colors(list[str | tuple] | str | tuple): Color(s) for the progress bars. If multiple progress bars are displayed, provide a list of colors for each progress bar. - bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set. - """ - @rpc_call - def set_line_widths(self, widths: "int | list[int]", bar_index: "int" = None): - """ - Set the line widths for the progress bars. +class SBBMonitor(RPCBase): + """A widget to display the SBB monitor website.""" - Args: - widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar. - bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set. - """ + ... - @rpc_call - def set_gap(self, gap: "int"): - """ - Set the gap between the progress bars. - Args: - gap(int): Gap between the progress bars. - """ +class ScanControl(RPCBase): + """Widget to submit new scans to the queue.""" @rpc_call - def set_diameter(self, diameter: "int"): + def attach(self): """ - Set the diameter of the widget. - - Args: - diameter(int): Diameter of the widget. + None """ @rpc_call - def reset_diameter(self): + def detach(self): """ - Reset the fixed size of the widget. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + @rpc_timeout(None) @rpc_call - def enable_auto_updates(self, enable: "bool" = True): + def screenshot(self, file_name: "str | None" = None): """ - Enable or disable updates based on scan status. Overrides manual updates. - The behaviour of the whole progress bar widget will be driven by the scan queue status. - - Args: - enable(bool): True or False. - - Returns: - bool: True if scan segment updates are enabled. + Take a screenshot of the dock area and save it to a file. """ -class SBBMonitor(RPCBase): - """A widget to display the SBB monitor website.""" - - ... - - -class ScanControl(RPCBase): - """Widget to submit new scans to the queue.""" +class ScanProgressBar(RPCBase): + """Widget to display a progress bar that is hooked up to the scan progress of a scan.""" @rpc_call def remove(self): @@ -4330,21 +4887,16 @@ def remove(self): Cleanup the BECConnector """ - @rpc_timeout(None) @rpc_call - def screenshot(self, file_name: "str | None" = None): + def attach(self): """ - Take a screenshot of the dock area and save it to a file. + None """ - -class ScanProgressBar(RPCBase): - """Widget to display a progress bar that is hooked up to the scan progress of a scan.""" - @rpc_call - def remove(self): + def detach(self): """ - Cleanup the BECConnector + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @@ -4366,6 +4918,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -4667,13 +5231,6 @@ def screenshot(self, file_name: "str | None" = None): Take a screenshot of the dock area and save it to a file. """ - @property - @rpc_call - def main_curve(self) -> "ScatterCurve": - """ - The main scatter curve item. - """ - @property @rpc_call def color_map(self) -> "str": @@ -4736,36 +5293,88 @@ def clear_all(self): Clear all the curves from the plot. """ + @property + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ -class SignalComboBox(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" + @x_device_name.setter + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + @property @rpc_call - def set_signal(self, signal: str): + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. """ - Set the signal. - Args: - signal (str): signal name. + @x_device_entry.setter + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. """ + @property @rpc_call - def set_device(self, device: str | None): + def y_device_name(self) -> "str": + """ + Device name for the Y axis. """ - Set the device. If device is not valid, device will be set to None which happens - Args: - device(str): device name. + @y_device_name.setter + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. """ @property @rpc_call - def signals(self) -> list[str]: + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. """ - Get the list of device signals for the applied filters. - Returns: - list[str]: List of device signals. + @y_device_entry.setter + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @property + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @z_device_name.setter + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @property + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ + + @z_device_entry.setter + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. """ @@ -4911,58 +5520,6 @@ def max_list_display_len(self) -> "int": """ -class SignalLineEdit(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" - - @property - @rpc_call - def _is_valid_input(self) -> bool: - """ - Check if the current value is a valid device name. - - Returns: - bool: True if the current value is a valid device name, False otherwise. - """ - - @rpc_call - def set_signal(self, signal: str): - """ - Set the signal. - - Args: - signal (str): signal name. - """ - - @rpc_call - def set_device(self, device: str | None): - """ - Set the device. If device is not valid, device will be set to None which happens - - Args: - device(str): device name. - """ - - @property - @rpc_call - def signals(self) -> list[str]: - """ - Get the list of device signals for the applied filters. - - Returns: - list[str]: List of device signals. - """ - - -class StopButton(RPCBase): - """A button that stops the current scan.""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - class TextBox(RPCBase): """A widget that displays text in plain and HTML format""" @@ -4985,12 +5542,6 @@ def set_html_text(self, text: str) -> None: """ -class VSCodeEditor(RPCBase): - """A widget to display the VSCode editor.""" - - ... - - class Waveform(RPCBase): """Widget for plotting waveforms.""" @@ -5000,6 +5551,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -5559,6 +6122,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class WebsiteWidget(RPCBase): """A simple widget to display a website""" @@ -5598,3 +6173,22 @@ def forward(self): """ Go forward in the history """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index bdcbd05ff..77a24e499 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -303,32 +303,62 @@ def new( wait: bool = True, geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", + profile: str | None = None, + start_empty: bool = False, **kwargs, - ) -> client.BECDockArea: + ) -> client.AdvancedDockArea: """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) + 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". + profile(str | None): The profile name to load. If None, loads the "general" profile. + Use a profile name to load a specific saved profile. + start_empty(bool): If True, start with an empty dock area when loading specified profile. + **kwargs: Additional keyword arguments passed to the dock area. + Returns: - client.BECDockArea: The new dock area. + client.AdvancedDockArea: The new dock area. + + Note: + The "general" profile is mandatory and will always exist. If manually deleted, + it will be automatically recreated. + + Examples: + >>> gui.new() # Start with the "general" profile + >>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name + >>> gui.new(start_empty=True) # Start with "general" profile but empty dock area + >>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area """ if not self._check_if_server_is_alive(): self.start(wait=True) if wait: with wait_for_server(self): widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + start_empty=start_empty, + **kwargs, ) # pylint: disable=protected-access return widget widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + start_empty=start_empty, + **kwargs, ) # pylint: disable=protected-access return widget def delete(self, name: str) -> None: - """Delete a dock area. + """Delete a dock area and its parent window. Args: name(str): The name of the dock area. @@ -336,7 +366,19 @@ def delete(self, name: str) -> None: widget = self.windows.get(name) if widget is None: raise ValueError(f"Dock area {name} not found.") - widget._run_rpc("close") # pylint: disable=protected-access + + # 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 def delete_all(self) -> None: """Delete all dock areas.""" @@ -392,7 +434,8 @@ def _gui_post_startup(self): timeout = 60 # Wait for 'bec' gui to be registered, this may take some time # After 60s timeout. Should this raise an exception on timeout? - while time.time() < time.time() + timeout: + start = time.monotonic() + while time.monotonic() < start + timeout: if len(list(self._server_registry.keys())) < 2 or not hasattr( self, self._anchor_widget ): diff --git a/bec_widgets/cli/generate_cli.py b/bec_widgets/cli/generate_cli.py index bbb323ff0..aeea572c9 100644 --- a/bec_widgets/cli/generate_cli.py +++ b/bec_widgets/cli/generate_cli.py @@ -291,7 +291,8 @@ def main(): client_path = module_dir / client_subdir / "client.py" - rpc_classes = get_custom_classes(module_name) + packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",) + rpc_classes = get_custom_classes(module_name, packages=packages) logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}") generator = ClientGenerator(base=module_name == "bec_widgets") diff --git a/bec_widgets/cli/rpc/rpc_register.py b/bec_widgets/cli/rpc/rpc_register.py index b984e3338..0b0077746 100644 --- a/bec_widgets/cli/rpc/rpc_register.py +++ b/bec_widgets/cli/rpc/rpc_register.py @@ -5,14 +5,13 @@ 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 @@ -109,11 +108,19 @@ def list_all_connections(self) -> dict: dict: A dictionary containing all the registered RPC objects. """ with self._lock: - connections = dict(self._rpc_register) + 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 return connections def get_names_of_rpc_by_class_type( - self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea] + self, cls: type[BECWidget] | type[BECConnector] ) -> list[str]: """Get all the names of the widgets. diff --git a/bec_widgets/cli/rpc/rpc_widget_handler.py b/bec_widgets/cli/rpc/rpc_widget_handler.py index b261d1b19..83d9a04d4 100644 --- a/bec_widgets/cli/rpc/rpc_widget_handler.py +++ b/bec_widgets/cli/rpc/rpc_widget_handler.py @@ -32,7 +32,8 @@ def update_available_widgets(self): None """ self._widget_classes = ( - get_custom_classes("bec_widgets") + get_all_plugin_widgets() + get_custom_classes("bec_widgets", packages=("widgets", "applications")) + + get_all_plugin_widgets() ).as_dict(IGNORE_WIDGETS) def create_widget(self, widget_type, **kwargs) -> BECWidget: diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index ea45c61e1..c3f8be487 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -7,8 +7,10 @@ import sys from contextlib import redirect_stderr, redirect_stdout +import darkdetect from bec_lib.logger import bec_logger from bec_lib.service_config import ServiceConfig +from bec_qthemes import apply_theme from qtmonaco.pylsp_provider import pylsp_server from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QIcon @@ -92,6 +94,11 @@ def _run(self): Run the GUI server. """ self.app = QApplication(sys.argv) + if darkdetect.isDark(): + apply_theme("dark") + else: + apply_theme("light") + self.app.setApplicationName("BEC") self.app.gui_id = self.gui_id # type: ignore self.setup_bec_icon() @@ -100,17 +107,19 @@ def _run(self): self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id) # self.dispatcher.start_cli_server(gui_id=self.gui_id) - self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher") + 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.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore self.app.aboutToQuit.connect(self.shutdown) 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 print("Caught SIGINT, exiting") diff --git a/bec_widgets/examples/general_app/general_app.py b/bec_widgets/examples/general_app/general_app.py deleted file mode 100644 index 8ea531ead..000000000 --- a/bec_widgets/examples/general_app/general_app.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import sys - -from qtpy.QtCore import QSize -from qtpy.QtGui import QActionGroup, QIcon -from qtpy.QtWidgets import QApplication, QMainWindow, QStyle - -import bec_widgets -from bec_widgets.examples.general_app.web_links import BECWebLinksMixin -from bec_widgets.utils.colors import apply_theme -from bec_widgets.utils.ui_loader import UILoader - -MODULE_PATH = os.path.dirname(bec_widgets.__file__) - - -class BECGeneralApp(QMainWindow): - def __init__(self, parent=None): - super(BECGeneralApp, self).__init__(parent) - ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui") - self.load_ui(ui_file_path) - - self.resize(1280, 720) - - self.ini_ui() - - def ini_ui(self): - self._setup_icons() - self._hook_menubar_docs() - self._hook_theme_bar() - - def load_ui(self, ui_file): - loader = UILoader(self) - self.ui = loader.loader(ui_file) - self.setCentralWidget(self.ui) - - def _hook_menubar_docs(self): - # BEC Docs - self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) - # BEC Widgets Docs - self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs) - # Bug report - self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) - - def change_theme(self, theme): - apply_theme(theme) - - def _setup_icons(self): - help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) - bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) - computer_icon = QIcon.fromTheme("computer") - widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png")) - - self.ui.action_BEC_docs.setIcon(help_icon) - self.ui.action_BEC_widgets_docs.setIcon(help_icon) - self.ui.action_bug_report.setIcon(bug_icon) - - self.ui.central_tab.setTabIcon(0, widget_icon) - self.ui.central_tab.setTabIcon(1, computer_icon) - - def _hook_theme_bar(self): - self.ui.action_light.setCheckable(True) - self.ui.action_dark.setCheckable(True) - - # Create an action group to make sure only one can be checked at a time - theme_group = QActionGroup(self) - theme_group.addAction(self.ui.action_light) - theme_group.addAction(self.ui.action_dark) - theme_group.setExclusive(True) - - # Connect the actions to the theme change method - - self.ui.action_light.triggered.connect(lambda: self.change_theme("light")) - self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark")) - - self.ui.action_dark.trigger() - - -def main(): # pragma: no cover - - app = QApplication(sys.argv) - icon = QIcon() - icon.addFile( - os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48) - ) - app.setWindowIcon(icon) - main_window = BECGeneralApp() - main_window.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/bec_widgets/examples/general_app/general_app.ui b/bec_widgets/examples/general_app/general_app.ui deleted file mode 100644 index 3a70bc77f..000000000 --- a/bec_widgets/examples/general_app/general_app.ui +++ /dev/null @@ -1,262 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1718 - 1139 - - - - MainWindow - - - QTabWidget::TabShape::Rounded - - - - - - - 0 - - - - Dock Area - - - - 2 - - - 1 - - - 2 - - - 2 - - - - - - - - - - - - Visual Studio Code - - - - 2 - - - 1 - - - 2 - - - 2 - - - - - - - - - - - - - - 0 - 0 - 1718 - 31 - - - - - Help - - - - - - - - Theme - - - - - - - - - - - Scan Control - - - 2 - - - - - - - - - - - - BEC Service Status - - - 2 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - Scan Queue - - - 2 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - - - - - - - - - - - - BEC Docs - - - - - - - - BEC Widgets Docs - - - - - - - - Bug Report - - - - - true - - - Light - - - - - true - - - Dark - - - - - - WebsiteWidget - QWebEngineView -
website_widget
-
- - BECQueue - QTableWidget -
bec_queue
-
- - ScanControl - QWidget -
scan_control
-
- - VSCodeEditor - WebsiteWidget -
vs_code_editor
-
- - BECStatusBox - QWidget -
bec_status_box
-
- - BECDockArea - QWidget -
dock_area
-
- - QWebEngineView - -
QtWebEngineWidgets/QWebEngineView
-
-
- - -
diff --git a/bec_widgets/examples/general_app/web_links.py b/bec_widgets/examples/general_app/web_links.py deleted file mode 100644 index 619e6d1e5..000000000 --- a/bec_widgets/examples/general_app/web_links.py +++ /dev/null @@ -1,15 +0,0 @@ -import webbrowser - - -class BECWebLinksMixin: - @staticmethod - def open_bec_docs(): - webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/") - - @staticmethod - def open_bec_widgets_docs(): - webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/") - - @staticmethod - def open_bec_bug_report(): - webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/") diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 6c80dd130..5cb0fefd1 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -1,12 +1,23 @@ +from __future__ import annotations + +import ast +import importlib import os +from typing import Any, Dict import numpy as np import pyqtgraph as pg from bec_qthemes import material_icon +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, + QComboBox, + QFrame, + QGridLayout, QGroupBox, QHBoxLayout, + QLabel, + QLineEdit, QPushButton, QSplitter, QTabWidget, @@ -14,147 +25,348 @@ QWidget, ) -from bec_widgets.utils import BECDispatcher +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.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole -from bec_widgets.widgets.plots.image.image import Image -from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap -from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform -from bec_widgets.widgets.plots.plot_base import PlotBase -from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform -from bec_widgets.widgets.plots.waveform.waveform import Waveform class JupyterConsoleWindow(QWidget): # pragma: no cover: - """A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API).""" + """A widget that contains a Jupyter console linked to BEC Widgets with full API access. + + Features: + - Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`. + - Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`. + - Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut. + - Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block. + """ - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self._widgets_by_name: Dict[str, QWidget] = {} self._init_ui() - # console push + # expose helper API and basics in the inprocess console if self.console.inprocess is True: - self.console.kernel_manager.kernel.shell.push( - { - "np": np, - "pg": pg, - "wh": wh, - "dock": self.dock, - "im": self.im, - # "mi": self.mi, - # "mm": self.mm, - # "lm": self.lm, - # "btn1": self.btn1, - # "btn2": self.btn2, - # "btn3": self.btn3, - # "btn4": self.btn4, - # "btn5": self.btn5, - # "btn6": self.btn6, - # "pb": self.pb, - # "pi": self.pi, - "wf": self.wf, - # "scatter": self.scatter, - # "scatter_mi": self.scatter, - # "mwf": self.mwf, - } - ) + # A thin API wrapper so users have a stable, minimal surface in the console + class _ConsoleAPI: + def __init__(self, win: "JupyterConsoleWindow"): + self._win = win + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None): + """Add an existing QWidget as a new tab and expose it in the console under `shortcut`.""" + return self._win.add_widget(widget, shortcut, title=title) + + def add_widget_by_class_path( + self, + class_path: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Import a QWidget class from `class_path`, instantiate it, and add it.""" + return self._win.add_widget_by_class_path( + class_path, shortcut, kwargs=kwargs, title=title + ) + + def add_widget_by_type( + self, + widget_type: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Instantiate a registered BEC widget by type string and add it.""" + return self._win.add_widget_by_type( + widget_type, shortcut, kwargs=kwargs, title=title + ) + + def list_widgets(self): + return list(self._win._widgets_by_name.keys()) + + def get_widget(self, shortcut: str) -> QWidget | None: + return self._win._widgets_by_name.get(shortcut) + + def available_widgets(self): + return list(widget_handler.widget_classes.keys()) + + self.jc = _ConsoleAPI(self) + self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh}) def _init_ui(self): self.layout = QHBoxLayout(self) - # Horizontal splitter + # Horizontal splitter: left = widgets tabs, right = console + add-widget panel splitter = QSplitter(self) self.layout.addWidget(splitter) - tab_widget = QTabWidget(splitter) - - first_tab = QWidget() - first_tab_layout = QVBoxLayout(first_tab) - self.dock = BECDockArea(gui_id="dock") - first_tab_layout.addWidget(self.dock) - tab_widget.addTab(first_tab, "Dock Area") - - # third_tab = QWidget() - # third_tab_layout = QVBoxLayout(third_tab) - # self.lm = LayoutManagerWidget() - # third_tab_layout.addWidget(self.lm) - # tab_widget.addTab(third_tab, "Layout Manager Widget") - # - # fourth_tab = QWidget() - # fourth_tab_layout = QVBoxLayout(fourth_tab) - # self.pb = PlotBase() - # self.pi = self.pb.plot_item - # fourth_tab_layout.addWidget(self.pb) - # tab_widget.addTab(fourth_tab, "PlotBase") - # - # tab_widget.setCurrentIndex(3) - # - group_box = QGroupBox("Jupyter Console", splitter) - group_box_layout = QVBoxLayout(group_box) + # Left: tabs that will host dynamically added widgets + self.tab_widget = QTabWidget(splitter) + + # Right: console area with an add-widget mini panel on top + right_panel = QGroupBox("Jupyter Console", splitter) + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(6, 12, 6, 6) + + # Add-widget mini panel + add_panel = QFrame(right_panel) + shape = QFrame.Shape.StyledPanel # PySide6 style enums + add_panel.setFrameShape(shape) + add_grid = QGridLayout(add_panel) + add_grid.setContentsMargins(8, 8, 8, 8) + add_grid.setHorizontalSpacing(8) + add_grid.setVerticalSpacing(6) + + instr = QLabel( + "Add a widget by class path or choose a registered BEC widget type," + " and expose it in the console under a shortcut.\n" + "Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform" + ) + instr.setWordWrap(True) + add_grid.addWidget(instr, 0, 0, 1, 2) + + # Registered widget selector + reg_label = QLabel("Registered") + reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.registry_combo = QComboBox(add_panel) + self.registry_combo.setEditable(False) + self.refresh_btn = QPushButton("Refresh") + reg_row = QHBoxLayout() + reg_row.addWidget(self.registry_combo) + reg_row.addWidget(self.refresh_btn) + add_grid.addWidget(reg_label, 1, 0) + add_grid.addLayout(reg_row, 1, 1) + + # Class path entry + class_label = QLabel("Class") + class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.class_path_edit = QLineEdit(add_panel) + self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)") + add_grid.addWidget(class_label, 2, 0) + add_grid.addWidget(self.class_path_edit, 2, 1) + + # Shortcut + shortcut_label = QLabel("Shortcut") + shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.shortcut_edit = QLineEdit(add_panel) + self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)") + add_grid.addWidget(shortcut_label, 3, 0) + add_grid.addWidget(self.shortcut_edit, 3, 1) + + # Kwargs + kwargs_label = QLabel("Kwargs") + kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.kwargs_edit = QLineEdit(add_panel) + self.kwargs_edit.setPlaceholderText( + 'Optional kwargs as dict literal, e.g. {"popups": True}' + ) + add_grid.addWidget(kwargs_label, 4, 0) + add_grid.addWidget(self.kwargs_edit, 4, 1) + + # Title + title_label = QLabel("Title") + title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.title_edit = QLineEdit(add_panel) + self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)") + add_grid.addWidget(title_label, 5, 0) + add_grid.addWidget(self.title_edit, 5, 1) + + # Buttons + btn_row = QHBoxLayout() + self.add_btn = QPushButton("Add by class path") + self.add_btn.clicked.connect(self._on_add_widget_clicked) + self.add_reg_btn = QPushButton("Add registered") + self.add_reg_btn.clicked.connect(self._on_add_registered_clicked) + btn_row.addStretch(1) + btn_row.addWidget(self.add_reg_btn) + btn_row.addWidget(self.add_btn) + add_grid.addLayout(btn_row, 6, 0, 1, 2) + + # Make the second column expand + add_grid.setColumnStretch(0, 0) + add_grid.setColumnStretch(1, 1) + + # Console widget self.console = BECJupyterConsole(inprocess=True) - group_box_layout.addWidget(self.console) - # - # # Some buttons for layout testing - # self.btn1 = QPushButton("Button 1") - # self.btn2 = QPushButton("Button 2") - # self.btn3 = QPushButton("Button 3") - # self.btn4 = QPushButton("Button 4") - # self.btn5 = QPushButton("Button 5") - # self.btn6 = QPushButton("Button 6") - # - fifth_tab = QWidget() - fifth_tab_layout = QVBoxLayout(fifth_tab) - self.wf = Waveform() - fifth_tab_layout.addWidget(self.wf) - tab_widget.addTab(fifth_tab, "Waveform Next Gen") - # - sixth_tab = QWidget() - sixth_tab_layout = QVBoxLayout(sixth_tab) - self.im = Image(popups=True) - self.mi = self.im.main_image - sixth_tab_layout.addWidget(self.im) - tab_widget.addTab(sixth_tab, "Image Next Gen") - tab_widget.setCurrentIndex(1) - # - # seventh_tab = QWidget() - # seventh_tab_layout = QVBoxLayout(seventh_tab) - # self.scatter = ScatterWaveform() - # self.scatter_mi = self.scatter.main_curve - # self.scatter.plot("samx", "samy", "bpm4i") - # seventh_tab_layout.addWidget(self.scatter) - # tab_widget.addTab(seventh_tab, "Scatter Waveform") - # tab_widget.setCurrentIndex(6) - # - # eighth_tab = QWidget() - # eighth_tab_layout = QVBoxLayout(eighth_tab) - # self.mm = MotorMap() - # eighth_tab_layout.addWidget(self.mm) - # tab_widget.addTab(eighth_tab, "Motor Map") - # tab_widget.setCurrentIndex(7) - # - # ninth_tab = QWidget() - # ninth_tab_layout = QVBoxLayout(ninth_tab) - # self.mwf = MultiWaveform() - # ninth_tab_layout.addWidget(self.mwf) - # tab_widget.addTab(ninth_tab, "MultiWaveform") - # tab_widget.setCurrentIndex(8) - # - # # add stuff to the new Waveform widget - # self._init_waveform() - # - # self.setWindowTitle("Jupyter Console Window") - - def _init_waveform(self): - self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel") - self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel") + + # Vertical splitter between add panel and console + right_splitter = QSplitter(Qt.Vertical, right_panel) + right_splitter.addWidget(add_panel) + right_splitter.addWidget(self.console) + right_splitter.setStretchFactor(0, 0) + right_splitter.setStretchFactor(1, 1) + right_splitter.setSizes([300, 600]) + + # Put splitter into the right group box + right_layout.addWidget(right_splitter) + + # Populate registry on startup + self._populate_registry_widgets() + + def _populate_registry_widgets(self): + try: + widget_handler.update_available_widgets() + items = sorted(widget_handler.widget_classes.keys()) + except Exception as exc: + print(f"Failed to load registered widgets: {exc}") + items = [] + self.registry_combo.clear() + self.registry_combo.addItems(items) + + def _on_add_widget_clicked(self): + class_path = self.class_path_edit.text().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not class_path or not shortcut: + print("Please provide both class path and shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + except Exception as exc: + print(f"Failed to instantiate {class_path}: {exc}") + return + + try: + self.add_widget(widget, shortcut, title=title) + except Exception as exc: + print(f"Failed to add widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _on_add_registered_clicked(self): + widget_type = self.registry_combo.currentText().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not widget_type or not shortcut: + print("Please select a registered widget and provide a shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title) + except Exception as exc: + print(f"Failed to add registered widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget: + module_path, _, class_name = class_path.rpartition(".") + if not module_path or not class_name: + raise ValueError("class_path must be of the form 'package.module.Class'") + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + if kwargs is None: + obj = cls() + else: + obj = cls(**kwargs) + if not isinstance(obj, QWidget): + raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}") + return obj + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget: + """Add a QWidget as a new tab and expose it in the Jupyter console. + + - widget: a QWidget instance to host in a new tab + - shortcut: variable name used in the console to access it + - title: optional tab title (defaults to shortcut or class name) + """ + if not isinstance(widget, QWidget): + raise TypeError("widget must be a QWidget instance") + if not shortcut or not shortcut.isidentifier(): + raise ValueError("shortcut must be a valid Python identifier") + if shortcut in self._widgets_by_name: + raise ValueError(f"A widget with shortcut '{shortcut}' already exists") + if self.console.inprocess is not True: + raise RuntimeError("Adding widgets and exposing them requires inprocess console") + + tab_title = title or shortcut or widget.__class__.__name__ + self.tab_widget.addTab(widget, tab_title) + self._widgets_by_name[shortcut] = widget + + # Expose in console under the given shortcut + self._push_to_console({shortcut: widget}) + return widget + + def add_widget_by_class_path( + self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + return self.add_widget(widget, shortcut, title=title) + + def add_widget_by_type( + self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + """Instantiate a registered BEC widget by its type string and add it as a tab. + + If kwargs does not contain `object_name`, it will default to the provided shortcut. + """ + # Ensure registry is loaded + widget_handler.update_available_widgets() + cls = widget_handler.widget_classes.get(widget_type) + if cls is None: + raise ValueError(f"Unknown registered widget type: {widget_type}") + + if kwargs is None: + kwargs = {"object_name": shortcut} + else: + kwargs = dict(kwargs) + kwargs.setdefault("object_name", shortcut) + + # Instantiate and add + widget = cls(**kwargs) + if not isinstance(widget, QWidget): + raise TypeError( + f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}" + ) + return self.add_widget(widget, shortcut, title=title) + + def _push_to_console(self, mapping: Dict[str, Any]): + """Push Python objects into the inprocess kernel user namespace.""" + if self.console.inprocess is True: + self.console.kernel_manager.kernel.shell.push(mapping) + else: + raise RuntimeError("Can only push variables when using inprocess kernel") def closeEvent(self, event): """Override to handle things when main window is closed.""" - self.dock.cleanup() - self.dock.close() + + # Ensure the embedded kernel and BEC client are shut down before window teardown + self.console.shutdown_kernel() self.console.close() super().closeEvent(event) @@ -168,18 +380,26 @@ def closeEvent(self, event): module_path = os.path.dirname(bec_widgets.__file__) app = QApplication(sys.argv) + apply_theme("dark") app.setApplicationName("Jupyter Console") app.setApplicationDisplayName("Jupyter Console") icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) app.setWindowIcon(icon) - bec_dispatcher = BECDispatcher(gui_id="jupyter_console") - client = bec_dispatcher.client - client.start() - win = JupyterConsoleWindow() + + # Examples: add two widgets programmatically to demonstrate usage + try: + win.add_widget_by_type("Waveform", shortcut="wf") + except Exception as exc: + print(f"Example add failed (Waveform by type): {exc}") + + try: + win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True}) + except Exception as exc: + print(f"Example add failed (Image by type): {exc}") + win.show() win.resize(1500, 800) - app.aboutToQuit.connect(win.close) sys.exit(app.exec_()) diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index bf4cdf0e2..0b47f367e 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -1,3 +1,4 @@ +# pylint: skip-file from unittest.mock import MagicMock from bec_lib.device import Device as BECDevice @@ -24,6 +25,16 @@ def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORE "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): @@ -255,6 +266,13 @@ def get_bec_signals(self, signal_class_name: str): 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 + DEVICES = [ FakePositioner("samx", limits=[-10, 10], read_value=2.0), diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 48a29fd1c..4b45662cd 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -8,20 +8,21 @@ 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 QObject, QRunnable, QThreadPool, QTimer, Signal +from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, 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",)) @@ -77,6 +78,8 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} + widget_removed = Signal() + name_established = Signal(str) def __init__( self, @@ -84,7 +87,6 @@ def __init__( 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, **kwargs, ): @@ -96,12 +98,13 @@ def __init__( config(ConnectionConfig, optional): The connection configuration with specific gui id. gui_id(str, optional): The GUI ID. object_name(str, optional): The object name. - parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473 root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object. **kwargs: """ # Extract object_name from kwargs to not pass it to Qt class object_name = object_name or kwargs.pop("objectName", None) + 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 @@ -117,7 +120,6 @@ def __init__( # 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: @@ -127,6 +129,17 @@ def __init__( def terminate(client=self.client, dispatcher=self.bec_dispatcher): logger.info("Disconnecting", repr(dispatcher)) dispatcher.disconnect_all() + + try: # shutdown ophyd threads if any + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() + logger.info("Ophyd dispatcher shut down successfully.") + except Exception as e: + logger.warning( + f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}" + ) + logger.info("Shutting down BEC Client", repr(client)) client.shutdown() @@ -172,7 +185,7 @@ def terminate(client=self.client, dispatcher=self.bec_dispatcher): # If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object. self.root_widget = root_widget - QTimer.singleShot(0, self._update_object_name) + self._update_object_name() @property def parent_id(self) -> str | None: @@ -193,7 +206,7 @@ def change_object_name(self, name: str) -> None: """ self.rpc_register.remove_rpc(self) self.setObjectName(name.replace("-", "_").replace(" ", "_")) - QTimer.singleShot(0, self._update_object_name) + self._update_object_name() def _update_object_name(self) -> None: """ @@ -204,6 +217,11 @@ def _update_object_name(self) -> None: self._enforce_unique_sibling_name() # 2) Register the object for RPC self.rpc_register.add_rpc(self) + try: + self.name_established.emit(self.object_name) + except RuntimeError as e: + logger.warning(f"Error emitting name_established signal: {e}") + return def _enforce_unique_sibling_name(self): """ @@ -213,23 +231,20 @@ def _enforce_unique_sibling_name(self): - If there's a nearest BECConnector parent, only compare with children of that parent. - If parent is None (i.e., top-level object), compare with all other top-level BECConnectors. """ - QApplication.sendPostedEvents() + if not shb.isValid(self): + return + parent_bec = WidgetHierarchy._get_becwidget_ancestor(self) if parent_bec: # We have a parent => only compare with siblings under that parent - siblings = parent_bec.findChildren(BECConnector) + siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)] else: # No parent => treat all top-level BECConnectors as siblings - # 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 + # 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] # Collect used names among siblings used_names = {sib.objectName() for sib in siblings if sib is not self} @@ -257,6 +272,8 @@ def setObjectName(self, name: str) -> None: 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): @@ -439,17 +456,14 @@ def on_config_update(self, config: ConnectionConfig | dict) -> None: 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. - elif hasattr(self, "close"): + if 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 else: self.rpc_register.remove_rpc(self) + self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS) def get_config(self, dict_output: bool = True) -> dict | BaseModel: """ @@ -467,6 +481,62 @@ def get_config(self, dict_output: bool = True) -> dict | BaseModel: 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 diff --git a/bec_widgets/utils/bec_list.py b/bec_widgets/utils/bec_list.py new file mode 100644 index 000000000..f125d04ec --- /dev/null +++ b/bec_widgets/utils/bec_list.py @@ -0,0 +1,93 @@ +from bec_lib.logger import bec_logger +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +logger = bec_logger.logger + + +class BECList(QListWidget): + """List Widget that manages ListWidgetItems with associated widgets.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {} + + def __contains__(self, key: str) -> bool: + return key in self._widget_map + + def add_widget_item(self, key: str, widget: QWidget): + """ + Add a widget to the list, mapping is associated with the given key. + + Args: + key (str): Key to associate with the widget. + widget (QWidget): Widget to add to the list. + """ + if key in self._widget_map: + self.remove_widget_item(key) + + item = QListWidgetItem() + item.setSizeHint(widget.sizeHint()) + self.insertItem(0, item) + self.setItemWidget(item, widget) + self._widget_map[key] = (item, widget) + + def remove_widget_item(self, key: str): + """ + Remove a widget by identifier key. + + Args: + key (str): Key associated with the widget to remove. + """ + if key not in self._widget_map: + return + + item, widget = self._widget_map.pop(key) + row = self.row(item) + self.takeItem(row) + try: + widget.close() + except Exception: + logger.debug(f"Could not close widget properly for key: {key}.") + try: + widget.deleteLater() + except Exception: + logger.debug(f"Could not delete widget properly for key: {key}.") + + def clear_widgets(self): + """Remove and destroy all widget items.""" + for key in list(self._widget_map.keys()): + self.remove_widget_item(key) + self._widget_map.clear() + self.clear() + + def get_widget(self, key: str) -> QWidget | None: + """Return the widget for a given key.""" + entry = self._widget_map.get(key) + return entry[1] if entry else None + + def get_item(self, key: str) -> QListWidgetItem | None: + """Return the QListWidgetItem for a given key.""" + entry = self._widget_map.get(key) + return entry[0] if entry else None + + def get_widgets(self) -> list[QWidget]: + """Return all managed widgets.""" + return [w for _, w in self._widget_map.values()] + + def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None: + """Return the widget associated with a given QListWidgetItem.""" + for itm, widget in self._widget_map.values(): + if itm == item: + return widget + return None + + def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None: + """Return the QListWidgetItem associated with a given widget.""" + for itm, w in self._widget_map.values(): + if w == widget: + return itm + return None + + def get_all_keys(self) -> list[str]: + """Return all keys for managed widgets.""" + return list(self._widget_map.keys()) diff --git a/bec_widgets/utils/bec_login.py b/bec_widgets/utils/bec_login.py new file mode 100644 index 000000000..6f75b51c5 --- /dev/null +++ b/bec_widgets/utils/bec_login.py @@ -0,0 +1,91 @@ +""" +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_()) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ed58aeb2a..e091e0a2e 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,19 +3,23 @@ from datetime import datetime from typing import TYPE_CHECKING -import darkdetect import shiboken6 from bec_lib.logger import bec_logger -from qtpy.QtCore import QObject -from qtpy.QtWidgets import QApplication, QFileDialog, QWidget +from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt +from qtpy.QtGui import QFont, QPixmap +from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, 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.colors import set_theme -from bec_widgets.utils.error_popups import SafeSlot +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 @@ -27,7 +31,7 @@ class BECWidget(BECConnector): # The icon name is the name of the icon in the icon theme, typically a name taken # from fonts.google.com/icons. Override this in subclasses to set the icon name. ICON_NAME = "widgets" - USER_ACCESS = ["remove"] + USER_ACCESS = ["remove", "attach", "detach"] # pylint: disable=too-many-arguments def __init__( @@ -36,7 +40,7 @@ def __init__( config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, - parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 + start_busy: bool = False, **kwargs, ): """ @@ -45,8 +49,7 @@ def __init__( >>> class MyWidget(BECWidget, QWidget): >>> def __init__(self, parent=None, client=None, config=None, gui_id=None): - >>> super().__init__(client=client, config=config, gui_id=gui_id) - >>> QWidget.__init__(self, parent=parent) + >>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id) Args: @@ -56,31 +59,31 @@ def __init__( theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the widget's apply_theme method will be called when the theme changes. """ - - super().__init__( - client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs - ) + super().__init__(client=client, config=config, gui_id=gui_id, **kwargs) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - app = QApplication.instance() - if not hasattr(app, "theme"): - # DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault - # Instead, we will set the theme to the system setting on startup - if darkdetect.isDark(): - set_theme("dark") - else: - set_theme("light") - if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + # Initialize optional busy loader overlay utility (lazy by default) + self._busy_overlay: "BusyLoaderOverlay" | None = None + self._busy_state_widget: QWidget | None = 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 + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() - if hasattr(qapp, "theme_signal"): - qapp.theme_signal.theme_updated.connect(self._update_theme) + if hasattr(qapp, "theme"): + SafeConnect(self, qapp.theme.theme_changed, self._update_theme) + @SafeSlot(str) + @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: @@ -89,8 +92,125 @@ def _update_theme(self, theme: str | None = None): theme = qapp.theme.theme else: theme = "dark" + 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. + 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: + + overlay = install_busy_loader(target=self, 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: + """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._busy_overlay.raise_() + self._busy_overlay.show() + + def set_busy(self, enabled: bool) -> 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. + + Args: + enabled(bool): Whether to enable the busy state. + """ + 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 enabled: + self._show_busy_overlay() + else: + self._busy_overlay.hide() + self._loading = bool(enabled) + + def is_busy(self) -> bool: + """ + Check if the loading overlay is enabled. + + Returns: + bool: True if the loading overlay is enabled, False otherwise. + """ + return bool(getattr(self, "_loading", False)) + @SafeSlot(str) def apply_theme(self, theme: str): """ @@ -100,6 +220,23 @@ def apply_theme(self, theme: str): theme(str, optional): The theme to be applied. """ + def _update_overlay_theme(self, theme: str): + try: + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None: + overlay._update_palette() + except Exception: + logger.warning(f"Failed to apply theme {theme} to {self}") + + def get_help_md(self) -> str: + """ + Method to override in subclasses to provide help text in markdown format. + + Returns: + str: The help text in markdown format. + """ + return "" + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) @@ -124,6 +261,70 @@ def screenshot(self, file_name: str | None = None): screenshot.save(file_name) logger.info(f"Screenshot saved to {file_name}") + def screenshot_bytes( + self, + *, + max_width: int | None = None, + max_height: int | None = None, + fmt: str = "PNG", + quality: int = -1, + ) -> QByteArray: + """ + Grab this widget, optionally scale to a max size, and return encoded image bytes. + + If max_width/max_height are omitted (the default), capture at full resolution. + + Args: + max_width(int, optional): Maximum width of the screenshot. + max_height(int, optional): Maximum height of the screenshot. + fmt(str, optional): Image format (e.g., "PNG", "JPEG"). + quality(int, optional): Image quality (0-100), -1 for default. + + Returns: + QByteArray: The screenshot image bytes. + """ + if not isinstance(self, QWidget): + return QByteArray() + + if not hasattr(self, "grab"): + raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}") + + pixmap: QPixmap = self.grab() + if pixmap.isNull(): + return QByteArray() + if max_width is not None or max_height is not None: + w = max_width if max_width is not None else pixmap.width() + h = max_height if max_height is not None else pixmap.height() + pixmap = pixmap.scaled( + w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation + ) + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buf, fmt, quality) + buf.close() + return ba + + def attach(self): + dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) + if dock is None: + return + + if not dock.isFloating(): + return + dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock) + + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) + if dock is None: + return + if dock.isFloating(): + return + dock.setFloating() + def cleanup(self): """Cleanup the widget.""" with RPCRegister.delayed_broadcast(): @@ -138,6 +339,25 @@ def cleanup(self): child.close() child.deleteLater() + # Tear down busy overlay explicitly to stop spinner and remove filters + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and shiboken6.isValid(overlay): + try: + overlay.hide() + filt = getattr(overlay, "_filter", None) + if filt is not None and shiboken6.isValid(filt): + try: + self.removeEventFilter(filt) + except Exception as exc: + logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + + # 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}") + def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" try: diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py new file mode 100644 index 000000000..9784c6cea --- /dev/null +++ b/bec_widgets/utils/busy_loader.py @@ -0,0 +1,325 @@ +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.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.error_popups import SafeProperty + +logger = bec_logger.logger + + +class _OverlayEventFilter(QObject): + """Keeps the overlay sized and stacked over its target widget.""" + + def __init__(self, target: QWidget, overlay: QWidget): + super().__init__(target) + self._target = target + self._overlay = overlay + + def eventFilter(self, obj, event): + if 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, + QEvent.LayoutRequest, + QEvent.Move, + ): + self._overlay.setGeometry(self._target.rect()) + self._overlay.raise_() + return False + + +class BusyLoaderOverlay(QWidget): + """ + A semi-transparent scrim with centered text and an animated spinner. + Call show()/hide() directly, or use via `install_busy_loader(...)`. + + Args: + parent(QWidget): The parent widget to overlay. + text(str): Initial text to display. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + + foreground_color_changed = Signal(QColor) + scrim_color_changed = Signal(QColor) + + def __init__(self, parent: QWidget, opacity: float = 0.35, **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) + + # Custom widget placeholder + self._custom_widget: QWidget | None = None + + # 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() + + # Start hidden; interactions beneath are blocked while visible + self.hide() + + @SafeProperty(QColor, notify=scrim_color_changed) + def scrim_color(self) -> QColor: + """ + 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. + + Args: + filt(QObject): The event filter instance. + """ + 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) + + 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. + + 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() + + ########################## + ### Internal methods ### + ########################## + + 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") + else: + # Fallback neutrals + _bg = QColor(30, 30, 30) + _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._frame.setStyleSheet( + f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}" + ) + self.update() + + ############################# + ### Custom Event Handlers ### + ############################# + + def showEvent(self, e): + # Call showEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.showEvent(e) + 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) + 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 install_busy_loader( + target: QWidget, 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. + 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.setGeometry(target.rect()) + overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay)) + if start_loading: + overlay.show() + return overlay + + +# -------------------------- +# Launchable demo +# -------------------------- +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() + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 9aa40c3ba..653fe56bd 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -1,18 +1,21 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Literal +from functools import lru_cache +from typing import Literal -import bec_qthemes import numpy as np import pyqtgraph as pg -from bec_qthemes._os_appearance.listener import OSThemeSwitchListener +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 -if TYPE_CHECKING: # pragma: no cover - from bec_qthemes._main import AccentColors +logger = bec_logger.logger def get_theme_name(): @@ -23,121 +26,129 @@ def get_theme_name(): def get_theme_palette(): - return bec_qthemes.load_palette(get_theme_name()) + # FIXME this is legacy code, should be removed in the future + app = QApplication.instance() + palette = app.palette() + return palette -def get_accent_colors() -> AccentColors | None: +def get_accent_colors() -> AccentColors: """ Get the accent colors for the current theme. These colors are extensions of the color palette and are used to highlight specific elements in the UI. """ if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): - return None + accent_colors = AccentColors() + return accent_colors return QApplication.instance().theme.accent_colors -def _theme_update_callback(): +def process_all_deferred_deletes(qapp): + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents(QEventLoop.AllEvents) + + +def apply_theme(theme: Literal["dark", "light"]): """ - Internal callback function to update the theme based on the system theme. + Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally. """ - app = QApplication.instance() - # pylint: disable=protected-access - app.theme.theme = app.os_listener._theme.lower() - app.theme_signal.theme_updated.emit(app.theme.theme) - apply_theme(app.os_listener._theme.lower()) + logger.info(f"Applying theme: {theme}") + process_all_deferred_deletes(QApplication.instance()) + apply_theme_global(theme) + process_all_deferred_deletes(QApplication.instance()) -def set_theme(theme: Literal["dark", "light", "auto"]): - """ - Set the theme for the application. +class Colors: + @staticmethod + def list_available_colormaps() -> list[str]: + """ + List colormap names available via the pyqtgraph colormap registry. - Args: - theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme. - """ - app = QApplication.instance() - bec_qthemes.setup_theme(theme, install_event_filter=False) + Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus). + """ - app.theme_signal.theme_updated.emit(theme) - apply_theme(theme) + 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 [] - if theme != "auto": - return + return [*_list(None), *_list("matplotlib"), *_list("colorcet")] - if not hasattr(app, "os_listener") or app.os_listener is None: - app.os_listener = OSThemeSwitchListener(_theme_update_callback) - app.installEventFilter(app.os_listener) + @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()) -def apply_theme(theme: Literal["dark", "light"]): - """ - Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead. - """ - app = QApplication.instance() - graphic_layouts = [ - child - for top in app.topLevelWidgets() - for child in top.findChildren(pg.GraphicsLayoutWidget) - ] - - plot_items = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.PlotItem) - ] - - histograms = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.HistogramLUTItem) - ] - - # Update background color based on the theme - if theme == "light": - background_color = "#e9ecef" # Subtle contrast for light mode - foreground_color = "#141414" - label_color = "#000000" - axis_color = "#666666" - else: - background_color = "#141414" # Dark mode - foreground_color = "#e9ecef" - label_color = "#FFFFFF" - axis_color = "#CCCCCC" - - # update GraphicsLayoutWidget - pg.setConfigOptions(foreground=foreground_color, background=background_color) - for pg_widget in graphic_layouts: - pg_widget.setBackground(background_color) - - # update PlotItems - for plot_item in plot_items: - for axis in ["left", "right", "top", "bottom"]: - plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color)) - plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color)) - - # Change title color - plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color) - - # Change legend color - if hasattr(plot_item, "legend") and plot_item.legend is not None: - plot_item.legend.setLabelTextColor(label_color) - # if legend is in plot item and theme is changed, has to be like that because of pg opt logic - for sample, label in plot_item.legend.items: - label_text = label.text - label.setText(label_text, color=label_color) - - # update HistogramLUTItem - for histogram in histograms: - histogram.axis.setPen(pg.mkPen(color=axis_color)) - histogram.axis.setTextPen(pg.mkPen(color=label_color)) - - # now define stylesheet according to theme and apply it - style = bec_qthemes.load_stylesheet(theme) - app.setStyleSheet(style) + @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) -class Colors: + 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: @@ -219,7 +230,7 @@ def evenly_spaced_colors( if theme_offset < 0 or theme_offset > 1: raise ValueError("theme_offset must be between 0 and 1") - cmap = pg.colormap.get(colormap) + cmap = Colors.get_colormap(colormap) min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset) # Generate positions that are evenly spaced within the acceptable range @@ -267,7 +278,7 @@ def golden_angle_color( ValueError: If theme_offset is not between 0 and 1. """ - cmap = pg.colormap.get(colormap) + cmap = Colors.get_colormap(colormap) phi = (1 + np.sqrt(5)) / 2 # Golden ratio golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 @@ -533,18 +544,103 @@ def validate_color_map(color_map: str, return_error: bool = True) -> str | bool: Raises: PydanticCustomError: If colormap is invalid. """ - 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: + normalized = Colors.canonical_colormap_name(color_map) + try: + Colors.get_colormap(normalized) + except Exception as ext: + logger.warning(f"Colormap validation error: {ext}") 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 on the following: {available_colormaps}.", + f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.", {"wrong_value": color_map}, ) else: return False - return color_map + 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) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index cb5203b8a..af8b48a2d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -11,6 +11,7 @@ QPushButton, QSizePolicy, QSpacerItem, + QToolButton, QVBoxLayout, QWidget, ) @@ -122,15 +123,14 @@ def __init__(self, parent=None, layout=QVBoxLayout): self.compact_view_widget = QWidget(self) self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) QHBoxLayout(self.compact_view_widget) - self.compact_view_widget.layout().setSpacing(0) + self.compact_view_widget.layout().setSpacing(5) self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0) self.compact_view_widget.layout().addSpacerItem( QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed) ) self.compact_label = QLabel(self.compact_view_widget) self.compact_status = LedLabel(self.compact_view_widget) - self.compact_show_popup = QPushButton(self.compact_view_widget) - self.compact_show_popup.setFlat(True) + self.compact_show_popup = QToolButton(self.compact_view_widget) self.compact_show_popup.setIcon( material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False) ) @@ -144,6 +144,7 @@ def __init__(self, parent=None, layout=QVBoxLayout): self.container.setVisible(True) layout(self.container) self.layout = self.container.layout() + self._compact_view = False self.compact_show_popup.clicked.connect(self.show_popup) @@ -210,7 +211,7 @@ def addWidget(self, widget): @Property(bool) def compact_view(self): - return self.compact_label.isVisible() + return self._compact_view @compact_view.setter def compact_view(self, set_compact: bool): @@ -220,6 +221,7 @@ def compact_view(self, set_compact: bool): the full view is displayed. This is handled by toggling visibility of the container widget or the compact view widget. """ + self._compact_view = set_compact if set_compact: self.compact_view_widget.setVisible(True) self.container.setVisible(False) diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index d2ead3dd1..efa270382 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -1,15 +1,38 @@ 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, QMessageBox, QPushButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QApplication, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) logger = bec_logger.logger +RAISE_ERROR_DEFAULT = False -def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs): + +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, +): """ 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. @@ -18,7 +41,15 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, 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. - *prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor. + 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). Usage: @SafeProperty(int, default=-1) @@ -30,6 +61,41 @@ def some_value(self) -> int: 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): @@ -49,6 +115,8 @@ def safe_getter(self_): 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(...). @@ -64,8 +132,42 @@ def setter(self, setter_func): @functools.wraps(setter_func) def safe_setter(self_, value): try: - return setter_func(self_, value) - except Exception: + 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}") prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}" error_msg = traceback.format_exc() @@ -90,6 +192,52 @@ def __call__(self): return decorator +def _safe_connect_slot(weak_instance, weak_slot, *connect_args): + """Internal function used by SafeConnect to handle weak references to slots.""" + instance = weak_instance() + slot_func = weak_slot() + + # Check if the python object has already been garbage collected + if instance is None or slot_func is None: + return + + # Check if the python object has already been marked for deletion + if getattr(instance, "_destroyed", False): + return + + # Check if the C++ object is still valid + if not shiboken6.isValid(instance): + return + + if connect_args: + slot_func(*connect_args) + slot_func() + + +def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name + """ + Method to safely handle Qt signal-slot connections. The python object is only forwarded + as a weak reference to avoid stale objects. + + Args: + instance: The instance to connect. + signal: The signal to connect to. + slot: The slot to connect. + + Example: + >>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme) + + """ + weak_instance = safe_ref(instance) + weak_slot = safe_ref(slot) + + # Create a partial function that will check weak references before calling the actual slot + safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot) + + # Connect the signal to the safe connect slot wrapper + return signal.connect(safe_slot) + + def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name """Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot to the passed function, to display errors instead of potentially raising an exception @@ -111,7 +259,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name _slot_params = { "popup_error": bool(slot_kwargs.pop("popup_error", False)), "verify_sender": bool(slot_kwargs.pop("verify_sender", False)), - "raise_error": bool(slot_kwargs.pop("raise_error", False)), + "raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)), } def error_managed(method): @@ -285,6 +433,100 @@ 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: ", self) + + self.spinbox_stored = QSpinBox(self) + self.spinbox_stored.setRange(-5, 10) + self.spinbox_stored.setValue(0) + self.label_stored = QLabel("stored emit: ", self) + + self.spinbox_input = QSpinBox(self) + self.spinbox_input.setRange(-5, 10) + self.spinbox_input.setValue(0) + self.label_input = QLabel("input emit: ", self) + + self.spinbox_callable = QSpinBox(self) + self.spinbox_callable.setRange(-5, 10) + self.spinbox_callable.setValue(0) + self.label_callable = QLabel("callable emit: ", 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. @@ -339,6 +581,10 @@ def trigger_warning(self): if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) - widget = ExampleWidget() - widget.show() + 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() sys.exit(app.exec_()) diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e0..08a4d95f4 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -1,7 +1,7 @@ from __future__ import annotations from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import QSize, Signal from qtpy.QtWidgets import ( QApplication, QFrame, @@ -19,7 +19,8 @@ class ExpandableGroupFrame(QFrame): - + broadcast_size_hint = Signal(QSize) + imminent_deletion = Signal() expansion_state_changed = Signal() EXPANDED_ICON_NAME: str = "collapse_all" @@ -31,10 +32,11 @@ def __init__( super().__init__(parent=parent) self._expanded = expanded - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) + self._title_text = f"{title}" + self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setContentsMargins(5, 0, 0, 0) self.setLayout(self._layout) self._create_title_layout(title, icon) @@ -49,21 +51,27 @@ def __init__( def _create_title_layout(self, title: str, icon: str): self._title_layout = QHBoxLayout() self._layout.addLayout(self._title_layout) + self._internal_title_layout = QHBoxLayout() + self._title_layout.addLayout(self._internal_title_layout) - self._title = ClickableLabel(f"{title}") + self._title = ClickableLabel() + self._set_title_text(self._title_text) self._title_icon = ClickableLabel() - self._title_layout.addWidget(self._title_icon) - self._title_layout.addWidget(self._title) + self._internal_title_layout.addWidget(self._title_icon) + self._internal_title_layout.addWidget(self._title) self.icon_name = icon self._title.clicked.connect(self.switch_expanded_state) self._title_icon.clicked.connect(self.switch_expanded_state) - self._title_layout.addStretch(1) + self._internal_title_layout.addStretch(1) self._expansion_button = QToolButton() self._update_expansion_icon() self._title_layout.addWidget(self._expansion_button, stretch=1) + def get_title_layout(self) -> QHBoxLayout: + return self._internal_title_layout + def set_layout(self, layout: QLayout) -> None: self._contents.setLayout(layout) self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore @@ -112,6 +120,18 @@ def _set_title_icon(self, icon_name: str): else: self._title_icon.setVisible(False) + @SafeProperty(str) + def title_text(self): # type: ignore + return self._title_text + + @title_text.setter + def title_text(self, title_text: str): + self._title_text = title_text + self._set_title_text(self._title_text) + + def _set_title_text(self, title_text: str): + self._title.setText(title_text) + # Application example if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/filter_io.py b/bec_widgets/utils/filter_io.py index b0f6700d9..1e3a316ee 100644 --- a/bec_widgets/utils/filter_io.py +++ b/bec_widgets/utils/filter_io.py @@ -7,6 +7,7 @@ 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 @@ -55,6 +56,49 @@ def update_with_kind( """ # 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""" @@ -255,6 +299,32 @@ def update_with_kind( 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): """ diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index eb5e31e61..9797af2e0 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from types import NoneType +from types import GenericAlias, NoneType, UnionType from typing import NamedTuple from bec_lib.logger import bec_logger @@ -11,7 +11,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.items import ( DynamicFormItem, @@ -215,6 +215,9 @@ def __init__( self._connect_to_theme_change() + @SafeSlot() + def clear(self): ... + def set_pretty_display_theme(self, theme: str = "dark"): if self._pretty_display: self.setStyleSheet(styles.pretty_display_theme(theme)) @@ -279,3 +282,24 @@ def validate_form(self, *_) -> bool: self.form_data_cleared.emit(None) self.validity_proc.emit(False) return False + + +class PydanticModelFormItem(DynamicFormItem): + def __init__( + self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] + ) -> None: + self._data_model = model + + super().__init__(parent=parent, spec=spec) + self._main_widget.form_data_updated.connect(self._value_changed) + + def _add_main_widget(self) -> None: + + self._main_widget = PydanticModelForm(data_model=self._data_model) + self._layout.addWidget(self._main_widget) + + def getValue(self): + return self._main_widget.get_form_data() + + def setValue(self, value: dict): + self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 04acf7ff3..a0b8e1f7a 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import typing from abc import abstractmethod from decimal import Decimal @@ -14,8 +15,10 @@ NamedTuple, Optional, OrderedDict, + Protocol, TypeVar, get_args, + runtime_checkable, ) from bec_lib.logger import bec_logger @@ -170,9 +173,10 @@ def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() + # Sadly, QWidget and ABC are not compatible assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore - self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) if not spec.pretty_display: if clearable_required(spec.info): self._add_clear_button() @@ -187,6 +191,7 @@ def setValue(self, value): ... @abstractmethod def _add_main_widget(self) -> None: + self._main_widget: QWidget """Add the main data entry widget to self._main_widget and appply any constraints from the field info""" @@ -404,7 +409,7 @@ def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None def sizeHint(self): default = super().sizeHint() - return QSize(default.width(), QFontMetrics(self.font()).height() * 6) + return QSize(default.width(), QFontMetrics(self.font()).height() * 4) def _add_main_widget(self) -> None: self._main_widget = QListWidget() @@ -454,10 +459,17 @@ def _add_data_item(self, val=None): self._add_list_item(val) self._repop(self._data) + def _item_height(self): + return int(QFontMetrics(self.font()).height() * 1.5) + def _add_list_item(self, val): item = QListWidgetItem(self._main_widget) item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) item_widget = self._types.widget(parent=self) + item_widget.setMinimumHeight(self._item_height()) + self._main_widget.setGridSize(QSize(0, self._item_height())) + if (layout := item_widget.layout()) is not None: + layout.setContentsMargins(0, 0, 0, 0) WidgetIO.set_value(item_widget, val) self._main_widget.setItemWidget(item, item_widget) self._main_widget.addItem(item) @@ -494,14 +506,11 @@ def setValue(self, value: Iterable): self._data = list(value) self._repop(self._data) - def _line_height(self): - return QFontMetrics(self._main_widget.font()).height() - def set_max_height_in_lines(self, lines: int): outer_inc = 1 if self._spec.pretty_display else 3 - self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines)) - self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1)) - self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc)) + self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines)) + self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1)) + self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc)) def scale_to_data(self, *_): self.set_max_height_in_lines(self._main_widget.count() + 1) @@ -584,6 +593,16 @@ def _add_main_widget(self) -> None: WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] +@runtime_checkable +class _ItemTypeFn(Protocol): + def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... + + +WidgetTypeRegistry = OrderedDict[ + str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] +] + + def _is_string_literal(t: type): return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str} @@ -637,7 +656,10 @@ def widget_from_type( widget_types = widget_types or DEFAULT_WIDGET_TYPES for predicate, widget_type in widget_types.values(): if predicate(spec): - return widget_type + if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): + return widget_type + return widget_type(spec) + logger.warning( f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." ) diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py new file mode 100644 index 000000000..4261c703b --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,735 @@ +"""Module providing a guided help system for creating interactive GUI tours.""" + +from __future__ import annotations + +import sys +import weakref +from typing import Callable, Dict, List, TypedDict +from uuid import uuid4 + +import louie +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from louie import saferef +from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal +from qtpy.QtGui import QAction, QColor, QPainter, QPen +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QMenuBar, + QPushButton, + QToolBar, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + +logger = bec_logger.logger + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[ + QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]] + ] + | Callable[[], tuple[QWidget | QAction, str | None]] + | None + ) + text: str + title: str + + +class TutorialOverlay(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # Keep mouse events enabled for the overlay but we'll handle them manually + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self.current_rect = QRect() + self.message_box = self._create_message_box() + self.message_box.hide() + + def _create_message_box(self): + box = QFrame(self) + app = QApplication.instance() + bg_color = app.palette().window().color() + box.setStyleSheet( + f""" + QFrame {{ + background-color: {bg_color.name()}; + border-radius: 8px; + padding: 8px; + }} + """ + ) + layout = QVBoxLayout(box) + + # Top layout with close button (left) and step indicator (right) + top_layout = QHBoxLayout() + + # Close button on the left with material icon + self.close_btn = QPushButton() + self.close_btn.setIcon(material_icon("close")) + self.close_btn.setToolTip("Close") + self.close_btn.setMaximumSize(32, 32) + + # Step indicator on the right + self.step_label = QLabel() + self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;") + + top_layout.addWidget(self.close_btn) + top_layout.addStretch() + top_layout.addWidget(self.step_label) + + # Main content label + self.label = QLabel() + self.label.setWordWrap(True) + + # Bottom navigation buttons + btn_layout = QHBoxLayout() + + # Back button with material icon + self.back_btn = QPushButton("Back") + self.back_btn.setIcon(material_icon("arrow_back")) + + # Next button with material icon + self.next_btn = QPushButton("Next") + self.next_btn.setIcon(material_icon("arrow_forward")) + + btn_layout.addStretch() + btn_layout.addWidget(self.back_btn) + btn_layout.addWidget(self.next_btn) + + layout.addLayout(top_layout) + layout.addWidget(self.label) + layout.addLayout(btn_layout) + return box + + def paintEvent(self, event): # pylint: disable=unused-argument + if not self.current_rect.isValid(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Create semi-transparent overlay color + overlay_color = QColor(0, 0, 0, 160) + # Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive. + r = self.current_rect + rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height() + + # Paint overlay in 4 regions around the highlighted widget using exclusive bounds + # Top region (everything above the highlight) + if rect_y > 0: + top_rect = QRect(0, 0, self.width(), rect_y) + painter.fillRect(top_rect, overlay_color) + + # Bottom region (everything below the highlight) + bottom_y = rect_y + rect_h + if bottom_y < self.height(): + bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y) + painter.fillRect(bottom_rect, overlay_color) + + # Left region (to the left of the highlight) + if rect_x > 0: + left_rect = QRect(0, rect_y, rect_x, rect_h) + painter.fillRect(left_rect, overlay_color) + + # Right region (to the right of the highlight) + right_x = rect_x + rect_w + if right_x < self.width(): + right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h) + painter.fillRect(right_rect, overlay_color) + + # Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap. + border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4) + painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(border_rect, 8, 8) + painter.end() + + def show_step( + self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1 + ): + """ + rect must already be in the overlay's coordinate space (i.e. mapped). + This method positions the message box so it does not overlap the rect. + + Args: + rect(QRect): rectangle to highlight + title(str): Title text for the step + text(str): Main content text for the step + current_step(int): Current step number + total_steps(int): Total number of steps in the tour + """ + self.current_rect = rect + + # Update step indicator in top right + self.step_label.setText(f"Step {current_step} of {total_steps}") + + # Update main content text (without step number since it's now in top right) + content_text = f"{title}
{text}" if title else text + self.label.setText(content_text) + self.message_box.adjustSize() # ensure layout applied + message_size = self.message_box.size() # actual widget size (width, height) + + spacing = 15 + + # Preferred placement: to the right, vertically centered + pos_x = rect.right() + spacing + pos_y = rect.center().y() - (message_size.height() // 2) + + # If it would go off the right edge, try left of the widget + if pos_x + message_size.width() > self.width(): + pos_x = rect.left() - message_size.width() - spacing + # vertical center is still good, but if that overlaps top/bottom we'll clamp below + + # If it goes off the left edge (no space either side), place below, centered horizontally + if pos_x < spacing: + pos_x = rect.center().x() - (message_size.width() // 2) + pos_y = rect.bottom() + spacing + + # If it goes off the bottom, try moving it above the widget + if pos_y + message_size.height() > self.height() - spacing: + # if there's room above the rect, put it there + candidate_y = rect.top() - message_size.height() - spacing + if candidate_y >= spacing: + pos_y = candidate_y + else: + # otherwise clamp to bottom with spacing + pos_y = max(spacing, self.height() - message_size.height() - spacing) + + # If it goes off the top, clamp down + pos_y = max(spacing, pos_y) + + # Make sure we don't poke the left edge + pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing)) + + # Apply geometry and show + self.message_box.setGeometry( + int(pos_x), int(pos_y), message_size.width(), message_size.height() + ) + self.message_box.show() + self.update() + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Resize: + self.setGeometry(obj.rect()) + return False + + +class GuidedTour(QObject): + """ + A guided help system for creating interactive GUI tours. + + Allows developers to register widgets with help text and create guided tours. + """ + + tour_started = Signal() + tour_finished = Signal() + step_changed = Signal(int, int) # current_step, total_steps + + def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True): + super().__init__() + self._visible_check: bool = enforce_visibility + self.main_window_ref = saferef.safe_ref(main_window) + self.overlay = None + self._registered_widgets: Dict[str, TourStep] = {} + self._tour_steps: List[TourStep] = [] + self._current_index = 0 + self._active = False + + @property + def main_window(self) -> QWidget | None: + """Get the main window from weak reference.""" + if self.main_window_ref and callable(self.main_window_ref): + widget = self.main_window_ref() + if isinstance(widget, QWidget): + return widget + return None + + def register_widget( + self, + *, + widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text. + text (str): The help text for the widget. This will be shown during the tour. + title (str, optional): A title for the widget (defaults to its class name or action text). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + # If it's a plain callable + if callable(widget) and not hasattr(widget, "__self__"): + widget_ref = widget + default_title = "Widget" + elif isinstance(widget, QAction): + widget_ref = weakref.ref(widget) + default_title = widget.text() or "Action" + elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button): + + def _resolve_toolbar_button(toolbar_action=widget): + button = toolbar_action.get_toolbar_button() + return (button, None) + + widget_ref = _resolve_toolbar_button + default_title = getattr(widget, "tooltip", "Toolbar Menu") + else: + widget_ref = saferef.safe_ref(widget) + default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget" + + self._registered_widgets[step_id] = { + "widget_ref": widget_ref, + "text": text, + "title": title or default_title, + } + logger.debug(f"Registered widget {title or default_title} with ID {step_id}") + return step_id + + def _action_highlight_rect(self, action: QAction) -> QRect | None: + """ + Try to find the QRect in main_window coordinates that should be highlighted for the given QAction. + Returns None if not found (e.g. not visible). + """ + mw = self.main_window + if mw is None: + return None + # Try toolbars first + for tb in mw.findChildren(QToolBar): + btn = tb.widgetForAction(action) + if btn and btn.isVisible(): + rect = btn.rect() + top_left = btn.mapTo(mw, rect.topLeft()) + return QRect(top_left, rect.size()) + # Try menu bars + menubars = [] + if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)): + mb = mw.menuBar() + if mb and mb not in menubars: + menubars.append(mb) + menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars] + for mb in menubars: + if action in mb.actions(): + ar = mb.actionGeometry(action) + top_left = mb.mapTo(mw, ar.topLeft()) + return QRect(top_left, ar.size()) + return None + + def unregister_widget(self, step_id: str) -> bool: + """ + Unregister a previously registered widget. + + Args: + step_id (str): The unique ID of the registered widget. + + Returns: + bool: True if the widget was unregistered, False if not found. + """ + if self._active: + raise RuntimeError("Cannot unregister widget while tour is active") + if step_id in self._registered_widgets: + if self._registered_widgets[step_id] in self._tour_steps: + self._tour_steps.remove(self._registered_widgets[step_id]) + del self._registered_widgets[step_id] + return True + return False + + def create_tour(self, step_ids: List[str] | None = None) -> bool: + """ + Create a tour from registered widget IDs. + + Args: + step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included. + + Returns: + bool: True if the tour was created successfully, False if any step IDs were not found + """ + if step_ids is None: + step_ids = list(self._registered_widgets.keys()) + + tour_steps = [] + for step_id in step_ids: + if step_id not in self._registered_widgets: + logger.error(f"Step ID {step_id} not found") + return False + tour_steps.append(self._registered_widgets[step_id]) + + self._tour_steps = tour_steps + logger.info(f"Created tour with {len(tour_steps)} steps") + return True + + @SafeSlot() + def start_tour(self): + """Start the guided tour.""" + if not self._tour_steps: + self.create_tour() + + if self._active: + logger.warning("Tour already active") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + return + + self._active = True + self._current_index = 0 + + # Create overlay + self.overlay = TutorialOverlay(main_window) + self.overlay.setGeometry(main_window.rect()) + self.overlay.show() + main_window.installEventFilter(self.overlay) + + # Connect signals + self.overlay.next_btn.clicked.connect(self.next_step) + self.overlay.back_btn.clicked.connect(self.prev_step) + self.overlay.close_btn.clicked.connect(self.stop_tour) + + main_window.installEventFilter(self) + self._show_current_step() + self.tour_started.emit() + logger.info("Started guided tour") + + @SafeSlot() + def stop_tour(self): + """Stop the current tour.""" + if not self._active: + return + + self._active = False + + main_window = self.main_window + if self.overlay and main_window: + main_window.removeEventFilter(self.overlay) + self.overlay.hide() + self.overlay.deleteLater() + self.overlay = None + + if main_window: + main_window.removeEventFilter(self) + self.tour_finished.emit() + logger.info("Stopped guided tour") + + @SafeSlot() + def next_step(self): + """Move to next step or finish tour if on last step.""" + if not self._active: + return + + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + # On last step, finish the tour + self.stop_tour() + + @SafeSlot() + def prev_step(self): + """Move to previous step.""" + if not self._active: + return + + if self._current_index > 0: + self._current_index -= 1 + self._show_current_step() + + def _show_current_step(self): + """Display the current step.""" + if not self._active or not self.overlay: + return + + step = self._tour_steps[self._current_index] + step_title = step["title"] + + target, step_text = self._resolve_step_target(step) + if target is None: + self._advance_past_invalid_step(step_title, reason="Step target no longer exists.") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + self.stop_tour() + return + + highlight_rect = self._get_highlight_rect(main_window, target, step_title) + if highlight_rect is None: + return + + # Calculate step numbers + current_step = self._current_index + 1 + total_steps = len(self._tour_steps) + + self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps) + + # Update button states + self.overlay.back_btn.setEnabled(self._current_index > 0) + + # Update next button text and state + is_last_step = self._current_index >= len(self._tour_steps) - 1 + if is_last_step: + self.overlay.next_btn.setText("Finish") + self.overlay.next_btn.setIcon(material_icon("check")) + self.overlay.next_btn.setEnabled(True) + else: + self.overlay.next_btn.setText("Next") + self.overlay.next_btn.setIcon(material_icon("arrow_forward")) + self.overlay.next_btn.setEnabled(True) + + self.step_changed.emit(self._current_index + 1, len(self._tour_steps)) + + def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]: + """ + Resolve the target widget/action for the given step. + + Args: + step(TourStep): The tour step to resolve. + + Returns: + tuple[QWidget | QAction | None, str]: The resolved target and the step text. + """ + widget_ref = step.get("widget_ref") + step_text = step.get("text", "") + + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + target = widget_ref() + else: + target = widget_ref + + if target is None: + return None, step_text + + if callable(target) and not isinstance(target, (QWidget, QAction)): + result = target() + if isinstance(result, tuple): + target, alt_text = result + if alt_text: + step_text = alt_text + else: + target = result + + return target, step_text + + def _get_highlight_rect( + self, main_window: QWidget, target: QWidget | QAction, step_title: str + ) -> QRect | None: + """ + Get the QRect in main_window coordinates to highlight for the given target. + + Args: + main_window(QWidget): The main window containing the target. + target(QWidget | QAction): The target widget or action to highlight. + step_title(str): The title of the current step (for logging purposes). + + Returns: + QRect | None: The rectangle to highlight, or None if not found/visible. + """ + if isinstance(target, QAction): + rect = self._action_highlight_rect(target) + if rect is None: + self._advance_past_invalid_step( + step_title, + reason=f"Could not find visible widget or menu for QAction {target.text()!r}.", + ) + return None + return rect + + if isinstance(target, QWidget): + if self._visible_check: + if not target.isVisible(): + self._advance_past_invalid_step( + step_title, reason=f"Widget {target!r} is not visible." + ) + return None + rect = target.rect() + top_left = target.mapTo(main_window, rect.topLeft()) + return QRect(top_left, rect.size()) + + self._advance_past_invalid_step( + step_title, reason=f"Unsupported step target type: {type(target)}" + ) + return None + + def _advance_past_invalid_step(self, step_title: str, *, reason: str): + """ + Skip the current step (or stop the tour) when the target cannot be visualised. + """ + logger.warning("%s Skipping step %r.", reason, step_title) + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + + def get_registered_widgets(self) -> Dict[str, TourStep]: + """Get all registered widgets.""" + return self._registered_widgets.copy() + + def clear_registrations(self): + """Clear all registered widgets.""" + if self._active: + self.stop_tour() + self._registered_widgets.clear() + self._tour_steps.clear() + logger.info("Cleared all registrations") + + def set_visibility_enforcement(self, enabled: bool): + """Enable or disable visibility checks when highlighting widgets.""" + self._visible_check = enabled + + def eventFilter(self, obj, event): + """Handle window resize/move events to update step positioning.""" + if event.type() in (QEvent.Type.Move, QEvent.Type.Resize): + if self._active: + self._show_current_step() + return super().eventFilter(obj, event) + + +################################################################################ +############ # Example usage of GuidedTour system ############################## +################################################################################ + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Guided Tour Demo") + central = QWidget() + layout = QVBoxLayout(central) + layout.setSpacing(12) + + layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support.")) + self.btn1 = QPushButton("Primary Button") + self.btn2 = QPushButton("Secondary Button") + self.status_label = QLabel("Use the controls below or the toolbar to interact.") + self.start_tour_btn = QPushButton("Start Guided Tour") + + layout.addWidget(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.status_label) + layout.addStretch() + layout.addWidget(self.start_tour_btn) + self.setCentralWidget(central) + + # Guided tour system + self.guided_help = GuidedTour(self) + + # Menus for demonstrating QAction support in menu bars + self._init_menu_bar() + + # Modular toolbar showcasing QAction targets + self._init_toolbar() + + # Register widgets and actions with help text + primary_step = self.guided_help.register_widget( + widget=self.btn1, + text="The primary button updates the status text when clicked.", + title="Primary Button", + ) + secondary_step = self.guided_help.register_widget( + widget=self.btn2, + text="The secondary button complements the demo layout.", + title="Secondary Button", + ) + toolbar_action_step = self.guided_help.register_widget( + widget=self.toolbar_tour_action.action, + text="Toolbar actions are supported in the guided tour. This one also starts the tour.", + title="Toolbar Tour Action", + ) + tools_menu_step = self.guided_help.register_widget( + widget=self.toolbar.components.get_action("menu_tools"), + text="Expandable toolbar menus group related actions. This button opens the tools menu.", + title="Tools Menu", + ) + + # Create tour from registered widgets + self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step] + widget_ids = self.tour_step_ids + self.guided_help.create_tour(widget_ids) + + # Connect start button + self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + + def _init_menu_bar(self): + menu_bar = self.menuBar() + info_menu = menu_bar.addMenu("Info") + info_menu.setObjectName("info-menu") + self.info_menu = info_menu + self.info_menu_action = info_menu.menuAction() + self.about_action = info_menu.addAction("About This Demo") + + def _init_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + self.addToolBar(self.toolbar) + + self.toolbar_tour_action = MaterialIconAction( + "play_circle", tooltip="Start the guided tour", parent=self + ) + self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action) + + self.toolbar_highlight_action = MaterialIconAction( + "visibility", tooltip="Highlight the primary button", parent=self + ) + self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action) + + demo_bundle = self.toolbar.new_bundle("demo") + demo_bundle.add_action("tour-start") + demo_bundle.add_action("inspect-primary") + + self._setup_tools_menu() + self.toolbar.show_bundles(["demo", "menu_tools"]) + self.toolbar.refresh() + + self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour) + + def _setup_tools_menu(self): + self.tools_menu_actions: dict[str, MaterialIconAction] = { + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add a note", filled=True, parent=self + ), + "bookmark": MaterialIconAction( + icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self + ), + "settings": MaterialIconAction( + icon_name="tune", tooltip="Adjust settings", filled=True, parent=self + ), + } + self.tools_menu_action = ExpandableMenuAction( + label="Tools ", actions=self.tools_menu_actions + ) + self.toolbar.components.add_safe("menu_tools", self.tools_menu_action) + bundle = ToolbarBundle("menu_tools", self.toolbar.components) + bundle.add_action("menu_tools") + self.toolbar.add_bundle(bundle) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + from bec_qthemes import apply_theme + + apply_theme("dark") + w = MainWindow() + w.resize(400, 300) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/help_inspector/__init__.py b/bec_widgets/utils/help_inspector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py new file mode 100644 index 000000000..9a73cd34c --- /dev/null +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -0,0 +1,247 @@ +"""Module providing a simple help inspector tool for QtWidgets.""" + +from functools import partial +from typing import Callable +from uuid import uuid4 + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import AccentColors, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.widget_io import WidgetHierarchy + +logger = bec_logger.logger + + +class HelpInspector(BECWidget, QtWidgets.QWidget): + """ + A help inspector widget that allows to inspect other widgets in the application. + Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget. + The method "get_help_md" is called on the widget which is added to the BECWidget base class. + It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown). + The inspector also allows to register custom callback that are called with the inspected widget + as argument. This may be useful in the future to hook up more callbacks with custom signals. + + Args: + parent (QWidget | None): The parent widget of the help inspector. + client: Optional client for BECWidget functionality. + size (tuple[int, int]): Optional size of the icon for the help inspector. + """ + + widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget + widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget + bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self._app = QtWidgets.QApplication.instance() + layout = QtWidgets.QHBoxLayout(self) # type: ignore + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._active = False + self._init_ui() + self._callbacks = {} + # Register the default callbacks + self._register_default_callbacks() + # Connect the button toggle signal + self._button.toggled.connect(self._toggle_mode) + + def _init_ui(self): + """Init the UI components.""" + colors: AccentColors = get_accent_colors() + self._button = QtWidgets.QToolButton(self.parent()) + self._button.setCheckable(True) + + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + self._button.setText("Help Inspect Tool") + self._button.setIcon(self._icon_unchecked()) + self._button.setToolTip("Click to enter Help Mode") + self.layout().addWidget(self._button) + + def apply_theme(self, theme: str) -> None: + colors = get_accent_colors() + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + if self._active: + self._button.setIcon(self._icon_checked()) + else: + self._button.setIcon(self._icon_unchecked()) + + @SafeSlot(bool) + def _toggle_mode(self, enabled: bool): + """ + Toggle the help inspection mode. + + Args: + enabled (bool): Whether to enable or disable the help inspection mode. + """ + if self._app is None: + self._app = QtWidgets.QApplication.instance() + self._active = enabled + if enabled: + self._app.installEventFilter(self) + self._button.setIcon(self._icon_checked()) + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor) + else: + self._app.removeEventFilter(self) + self._button.setIcon(self._icon_unchecked()) + self._button.setChecked(False) + QtWidgets.QApplication.restoreOverrideCursor() + + def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool: + """ + Filter events to capture Key_Escape event, and mouse clicks + if event filter is active. Any click event on a widget is suppressed, if + the Inspector is active, and the registered callbacks are called with + the clicked widget as argument. + + Args: + obj (QObject): The object that received the event. + event (QEvent): The event to filter. + """ + # If not active, return immediately + if not self._active: + return super().eventFilter(obj, event) + # If active, handle escape key + if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape: + self._toggle_mode(False) + return super().eventFilter(obj, event) + # If active, and left mouse button pressed, handle click + if event.type() == QtCore.QEvent.MouseButtonPress: + if event.button() == QtCore.Qt.LeftButton: + widget = self._app.widgetAt(event.globalPos()) + if widget is None: + return super().eventFilter(obj, event) + # Get BECWidget ancestor + # TODO check what happens if the HELP Inspector itself is embedded in another BECWidget + # I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one + if not isinstance(widget, BECWidget): + widget = WidgetHierarchy._get_becwidget_ancestor(widget) + if widget: + if widget is self: + self._toggle_mode(False) + return True + for cb in self._callbacks.values(): + try: + cb(widget) + except Exception as e: + logger.error(f"Error occurred in callback {cb}: {e}") + return True + return super().eventFilter(obj, event) + + def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str: + """ + Register a callback to be called when a widget is inspected. + The callback should be callable with the following signature: + callback(widget: QWidget) -> None + + Args: + callback (Callable[[QWidget], None]): The callback function to register. + Returns: + str: A unique ID for the registered callback. + """ + cb_id = str(uuid4()) + self._callbacks[cb_id] = callback + return cb_id + + def unregister_callback(self, cb_id: str): + """Unregister a previously registered callback.""" + self._callbacks.pop(cb_id, None) + + def _register_default_callbacks(self): + """Default behavior: publish tooltip, docstring, bec_help""" + + def cb_doc(widget: QtWidgets.QWidget): + docstring = widget.__doc__ or "No documentation available." + self.widget_docstring.emit(docstring) + + def cb_help(widget: QtWidgets.QWidget): + tooltip = widget.toolTip() or "No tooltip available." + self.widget_tooltip.emit(tooltip) + + def cb_bec_help(widget: QtWidgets.QWidget): + help_text = None + if hasattr(widget, "get_help_md") and callable(widget.get_help_md): + try: + help_text = widget.get_help_md() + except Exception as e: + logger.debug(f"Error retrieving help text from {widget}: {e}") + if help_text is None: + help_text = widget.toolTip() or "No help available." + if not isinstance(help_text, str): + logger.error( + f"Help text from {widget.__class__} is not a string: {type(help_text)}" + ) + help_text = str(help_text) + self.bec_widget_help.emit(help_text) + + self.register_callback(cb_doc) + self.register_callback(cb_help) + self.register_callback(cb_bec_help) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + + from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QtWidgets.QApplication(sys.argv) + + main_window = QtWidgets.QMainWindow() + apply_theme("dark") + main_window.setWindowTitle("Help Inspector Test") + + central_widget = QtWidgets.QWidget() + main_layout = QtWidgets.QVBoxLayout(central_widget) + dark_mode_button = DarkModeButton(parent=main_window) + main_layout.addWidget(dark_mode_button) + + help_inspector = HelpInspector() + main_layout.addWidget(help_inspector) + + test_button = QtWidgets.QPushButton("Test Button") + test_button.setToolTip("This is a test button.") + test_line_edit = QtWidgets.QLineEdit() + test_line_edit.setToolTip("This is a test line edit.") + test_label = QtWidgets.QLabel("Test Label") + test_label.setToolTip("") + box = PositionerBox() + + layout_1 = QtWidgets.QHBoxLayout() + layout_1.addWidget(test_button) + layout_1.addWidget(test_line_edit) + layout_1.addWidget(test_label) + layout_1.addWidget(box) + main_layout.addLayout(layout_1) + + doc_label = QtWidgets.QLabel("Docstring will appear here.") + tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.") + bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.") + main_layout.addWidget(doc_label) + main_layout.addWidget(tool_tip_label) + main_layout.addWidget(bec_help_label) + + help_inspector.widget_tooltip.connect(tool_tip_label.setText) + help_inspector.widget_docstring.connect(doc_label.setText) + help_inspector.bec_widget_help.connect(bec_help_label.setText) + + main_window.setCentralWidget(central_widget) + main_window.resize(400, 200) + main_window.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py new file mode 100644 index 000000000..7ad85a713 --- /dev/null +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -0,0 +1,133 @@ +import re +from functools import partial +from re import Pattern +from typing import Generic, Iterable, NamedTuple, TypeVar + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import ( + SORT_KEY_ROLE, + SortableQListWidgetItem, +) + +logger = bec_logger.logger + + +_EF = TypeVar("_EF", bound=ExpandableGroupFrame) + + +class ListOfExpandableFrames(QListWidget, Generic[_EF]): + def __init__( + self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame + ) -> None: + super().__init__(parent) + _Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF))) + self.item_tuple = _Items + self._item_class = item_class + self._item_dict: dict[str, _Items] = {} + + def __contains__(self, id: str): + return id in self._item_dict + + def clear(self) -> None: + self._item_dict = {} + return super().clear() + + def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]: + """Adds the specified type of widget as an item. args and kwargs are passed to the constructor. + + Args: + id (str): the key under which to store the list item in the internal dict + + Returns: + The widget created in the addition process + """ + + def _remove_item(item: QListWidgetItem): + self.takeItem(self.row(item)) + del self._item_dict[id] + self.sortItems() + + def _updatesize(item: QListWidgetItem, item_widget: _EF): + item_widget.adjustSize() + item.setSizeHint(QSize(item_widget.width(), item_widget.height())) + + item = SortableQListWidgetItem(self) + item.setData(SORT_KEY_ROLE, id) # used for sorting + + item_widget = self._item_class(*args, **kwargs) + item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget)) + item_widget.imminent_deletion.connect(partial(_remove_item, item)) + item_widget.broadcast_size_hint.connect(item.setSizeHint) + + self.addItem(item) + self.setItemWidget(item, item_widget) + self._item_dict[id] = self.item_tuple(item, item_widget) + + item.setSizeHint(item_widget.sizeHint()) + return (item, item_widget) + + def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder): + items = [self.takeItem(0) for i in range(self.count())] + items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder)) + + for it in items: + self.addItem(it) + # reattach its custom widget + widget = self.itemWidget(it) + if widget: + self.setItemWidget(it, widget) + + def item_widget_pairs(self): + return self._item_dict.values() + + def widgets(self): + return (i.widget for i in self._item_dict.values()) + + def get_item_widget(self, id: str): + if (item := self._item_dict.get(id)) is None: + return None + return item + + def set_hidden_pattern(self, pattern: Pattern): + self.hide_all() + self._set_hidden(filter(pattern.search, self._item_dict.keys()), False) + + def set_hidden(self, ids: Iterable[str]): + self._set_hidden(ids, True) + + def _set_hidden(self, ids: Iterable[str], hidden: bool): + for id in ids: + if (_item := self._item_dict.get(id)) is not None: + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + else: + logger.warning( + f"List {self.__qualname__} does not have an item with ID {id} to hide!" + ) + self.sortItems() + + def hide_all(self): + self.set_hidden_state_on_all(True) + + def unhide_all(self): + self.set_hidden_state_on_all(False) + + def set_hidden_state_on_all(self, hidden: bool): + for _item in self._item_dict.values(): + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + self.sortItems() + + @SafeSlot(str) + def update_filter(self, value: str): + if value == "": + return self.unhide_all() + try: + self.set_hidden_pattern(re.compile(value, re.IGNORECASE)) + except Exception: + self.unhide_all() diff --git a/bec_widgets/utils/name_utils.py b/bec_widgets/utils/name_utils.py index 02fb7b2d1..45c15f058 100644 --- a/bec_widgets/utils/name_utils.py +++ b/bec_widgets/utils/name_utils.py @@ -14,3 +14,22 @@ 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) diff --git a/bec_widgets/utils/plugin_utils.py b/bec_widgets/utils/plugin_utils.py index 1150b3dad..32ac9c9d1 100644 --- a/bec_widgets/utils/plugin_utils.py +++ b/bec_widgets/utils/plugin_utils.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Iterable from bec_lib.plugin_helper import _get_available_plugins -from qtpy.QtWidgets import QGraphicsWidget, QWidget +from qtpy.QtWidgets import QWidget from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_widget import BECWidget @@ -166,18 +166,17 @@ def classes(self): return [info.obj for info in self.collection] -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. - """ +def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer: + """Collect classes from a package subtree (for example ``widgets`` or ``applications``).""" collection = BECClassContainer() - anchor_module = importlib.import_module(f"{repo_name}.widgets") + 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 + directory = os.path.dirname(anchor_module.__file__) for root, _, files in sorted(os.walk(directory)): for file in files: @@ -185,13 +184,13 @@ def get_custom_classes(repo_name: str) -> BECClassContainer: continue path = os.path.join(root, file) - subs = os.path.dirname(os.path.relpath(path, directory)).split("/") - if len(subs) == 1 and not subs[0]: + rel_dir = os.path.dirname(os.path.relpath(path, directory)) + if rel_dir in ("", "."): module_name = file.split(".")[0] else: - module_name = ".".join(subs + [file.split(".")[0]]) + module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]]) - module = importlib.import_module(f"{repo_name}.widgets.{module_name}") + module = importlib.import_module(f"{repo_name}.{package}.{module_name}") for name in dir(module): obj = getattr(module, name) @@ -203,12 +202,30 @@ def get_custom_classes(repo_name: str) -> BECClassContainer: 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 ``.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 diff --git a/bec_widgets/utils/reference_utils.py b/bec_widgets/utils/reference_utils.py index 6766a13ea..dc4ef78ef 100644 --- a/bec_widgets/utils/reference_utils.py +++ b/bec_widgets/utils/reference_utils.py @@ -1,5 +1,6 @@ import os import sys +from typing import Any from PIL import Image, ImageChops from qtpy.QtGui import QPixmap @@ -40,7 +41,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 diff --git a/bec_widgets/utils/round_frame.py b/bec_widgets/utils/round_frame.py index 51ec34979..f8399d1b3 100644 --- a/bec_widgets/utils/round_frame.py +++ b/bec_widgets/utils/round_frame.py @@ -1,11 +1,12 @@ import pyqtgraph as pg -from qtpy.QtCore import Property +from qtpy.QtCore import Property, Qt from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton class RoundedFrame(QFrame): + # TODO this should be removed completely in favor of QSS styling, no time now """ A custom QFrame with rounded corners and optional theme updates. The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets. @@ -28,6 +29,9 @@ def __init__( self.setProperty("skip_settings", True) self.setObjectName("roundedFrame") + # Ensure QSS can paint background/border on this widget + self.setAttribute(Qt.WA_StyledBackground, True) + # Create a layout for the frame if orientation == "vertical": self.layout = QVBoxLayout(self) @@ -45,22 +49,10 @@ def __init__( # Automatically apply initial styles to the GraphicalLayoutWidget if applicable self.apply_plot_widget_style() + self.update_style() def apply_theme(self, theme: str): - """ - Apply the theme to the frame and its content if theme updates are enabled. - """ - if self.content_widget is not None and isinstance( - self.content_widget, pg.GraphicsLayoutWidget - ): - self.content_widget.setBackground(self.background_color) - - # Update background color based on the theme - if theme == "light": - self.background_color = "#e9ecef" # Subtle contrast for light mode - else: - self.background_color = "#141414" # Dark mode - + """Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven.""" self.update_style() @Property(int) @@ -77,34 +69,21 @@ def update_style(self): """ Update the style of the frame based on the background color. """ - if self.background_color: - self.setStyleSheet( - f""" + self.setStyleSheet( + f""" QFrame#roundedFrame {{ - background-color: {self.background_color}; - border-radius: {self._radius}; /* Rounded corners */ + border-radius: {self._radius}px; }} """ - ) + ) self.apply_plot_widget_style() def apply_plot_widget_style(self, border: str = "none"): """ - Automatically apply background, border, and axis styles to the PlotWidget. - - Args: - border (str): Border style (e.g., 'none', '1px solid red'). + Let QSS/pyqtgraph handle plot styling; avoid overriding here. """ if isinstance(self.content_widget, pg.GraphicsLayoutWidget): - # Apply border style via stylesheet - self.content_widget.setStyleSheet( - f""" - GraphicsLayoutWidget {{ - border: {border}; /* Explicitly set the border */ - }} - """ - ) - self.content_widget.setBackground(self.background_color) + self.content_widget.setStyleSheet("") class ExampleApp(QWidget): # pragma: no cover @@ -128,24 +107,14 @@ def __init__(self): plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r") plot2.plot_item = plot_item_2 - # Wrap PlotWidgets in RoundedFrame - rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1) - rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2) - - # Add to layout + # Add to layout (no RoundedFrame wrapper; QSS styles plots) layout.addWidget(dark_button) - layout.addWidget(rounded_plot1) - layout.addWidget(rounded_plot2) + layout.addWidget(plot1) + layout.addWidget(plot2) self.setLayout(layout) - from qtpy.QtCore import QTimer - - def change_theme(): - rounded_plot1.apply_theme("light") - rounded_plot2.apply_theme("dark") - - QTimer.singleShot(100, change_theme) + # Theme flip demo removed; global theming applies automatically if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index a8026aaad..ed53f78cf 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -1,7 +1,6 @@ from __future__ import annotations import functools -import time import traceback import types from contextlib import contextmanager @@ -12,7 +11,6 @@ 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 QApplication from redis.exceptions import RedisError from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -32,6 +30,10 @@ 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""" @@ -55,6 +57,19 @@ def custom_exception_hook(self, exc_type, value, tb, **kwargs): 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 @@ -86,6 +101,7 @@ def __init__( 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}") @@ -109,7 +125,8 @@ def on_rpc_update(self, msg: dict, metadata: dict): self.send_response(request_id, False, {"error": content}) else: logger.debug(f"RPC instruction executed successfully: {res}") - self.send_response(request_id, True, {"result": res}) + self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res)) def send_response(self, request_id: str, accepted: bool, msg: dict): self.client.connector.set_and_publish( @@ -167,14 +184,61 @@ def run_rpc(self, obj, method, args, kwargs): res = None else: res = method_obj(*args, **kwargs) + return res + + 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) - return 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) def serialize_object(self, obj: T) -> None | dict | T: """ @@ -256,11 +320,8 @@ def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict: except Exception: container_proxy = None - if wait: - while not self.rpc_register.object_is_registered(connector): - QApplication.processEvents() - logger.info(f"Waiting for {connector} to be registered...") - time.sleep(0.1) + if wait and not self.rpc_register.object_is_registered(connector): + raise RegistryNotReadyError(f"Connector {connector} not registered yet") widget_class = getattr(connector, "rpc_widget_class", None) if not widget_class: diff --git a/bec_widgets/utils/screen_utils.py b/bec_widgets/utils/screen_utils.py new file mode 100644 index 000000000..122086c65 --- /dev/null +++ b/bec_widgets/utils/screen_utils.py @@ -0,0 +1,100 @@ +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)) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4e915cb85..f82753ffe 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import weakref from abc import ABC, abstractmethod from contextlib import contextmanager from typing import Dict, Literal @@ -10,7 +11,7 @@ from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QAction, QColor, QIcon +from qtpy.QtGui import QAction, QColor, QIcon # type: ignore from qtpy.QtWidgets import ( QApplication, QComboBox, @@ -25,6 +26,7 @@ ) 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 @@ -33,13 +35,39 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) +def create_action_with_text(toolbar_action, toolbar: QToolBar): + """ + Helper function to create a toolbar button with text beside or under the icon. + + Args: + toolbar_action(ToolBarAction): The toolbar action to create the button for. + toolbar(ModularToolBar): The toolbar to add the button to. + """ + + btn = QToolButton(parent=toolbar) + if getattr(toolbar_action, "label_text", None): + toolbar_action.action.setText(toolbar_action.label_text) + if getattr(toolbar_action, "tooltip", None): + toolbar_action.action.setToolTip(toolbar_action.tooltip) + btn.setToolTip(toolbar_action.tooltip) + + btn.setDefaultAction(toolbar_action.action) + btn.setAutoRaise(True) + if toolbar_action.text_position == "beside": + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + else: + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + btn.setText(toolbar_action.label_text) + toolbar.addWidget(btn) + + class NoCheckDelegate(QStyledItemDelegate): """To reduce space in combo boxes by removing the checkmark.""" def initStyleOption(self, option, index): super().initStyleOption(option, index) # Remove any check indicator - option.checkState = Qt.Unchecked + option.checkState = Qt.CheckState.Unchecked class LongPressToolButton(QToolButton): @@ -84,13 +112,15 @@ class ToolBarAction(ABC): checkable (bool, optional): Whether the action is checkable. Defaults to False. """ - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): + def __init__( + self, icon_path: str | None = None, tooltip: str | None = None, checkable: bool = False + ): self.icon_path = ( os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None ) - self.tooltip = tooltip - self.checkable = checkable - self.action = None + self.tooltip: str = tooltip or "" + self.checkable: bool = checkable + self.action: QAction @abstractmethod def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): @@ -106,6 +136,11 @@ def cleanup(self): pass +class IconAction(ToolBarAction): + @abstractmethod + def get_icon(self) -> QIcon: ... + + class SeparatorAction(ToolBarAction): """Separator action for the toolbar.""" @@ -113,56 +148,91 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addSeparator() -class QtIconAction(ToolBarAction): - def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): +class QtIconAction(IconAction): + def __init__( + self, + standard_icon, + tooltip=None, + checkable=False, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, + parent=None, + ): + """ + Action with a standard Qt icon for the toolbar. + + Args: + standard_icon: The standard icon from QStyle. + tooltip(str, optional): The tooltip for the action. Defaults to None. + checkable(bool, optional): Whether the action is checkable. Defaults to False. + label_text(str | None, optional): Optional label text to display beside or under the icon. + text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon. + parent(QWidget or None, optional): Parent widget for the underlying QAction. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.standard_icon = standard_icon self.icon = QApplication.style().standardIcon(standard_icon) self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) self.action.setCheckable(self.checkable) + self.label_text = label_text + self.text_position = text_position def add_to_toolbar(self, toolbar, target): - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): return self.icon -class MaterialIconAction(ToolBarAction): +class MaterialIconAction(IconAction): """ Action with a Material icon for the toolbar. Args: - icon_name (str, optional): The name of the Material icon. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. + icon_name (str): The name of the Material icon. + tooltip (str): The tooltip for the action. checkable (bool, optional): Whether the action is checkable. Defaults to False. filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. Defaults to None. + label_text (str | None, optional): Optional label text to display beside or under the icon. + text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon. parent (QWidget or None, optional): Parent widget for the underlying QAction. """ def __init__( self, - icon_name: str = None, - tooltip: str = None, + icon_name: str, + tooltip: str, + *, checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, parent=None, ): + """ + MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon. + This enables per-action icon text without breaking the existing API. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.icon_name = icon_name self.filled = filled self.color = color + self.label_text = label_text + self.text_position = text_position # Generate the icon using the material_icon helper - self.icon = material_icon( + self.icon: QIcon = material_icon( self.icon_name, size=(20, 20), convert_to_pixmap=False, filled=self.filled, color=self.color, - ) + ) # type: ignore if parent is None: logger.warning( "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." @@ -178,7 +248,10 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar(QToolBar): The toolbar to add the action to. target(QWidget): The target widget for the action. """ - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): """ @@ -195,11 +268,11 @@ class DeviceSelectionAction(ToolBarAction): Action for selecting a device in a combobox. Args: - label (str): The label for the combobox. device_combobox (DeviceComboBox): The combobox for selecting the device. + label (str): The label for the combobox. """ - def __init__(self, label: str | None = None, device_combobox=None): + def __init__(self, /, device_combobox: DeviceComboBox, label: str | None = None): super().__init__() self.label = label self.device_combobox = device_combobox @@ -221,7 +294,7 @@ def set_combobox_style(self, color: str): self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") -class SwitchableToolBarAction(ToolBarAction): +class SwitchableToolBarAction(IconAction): """ A split toolbar action that combines a main action and a drop-down menu for additional actions. @@ -241,9 +314,9 @@ class SwitchableToolBarAction(ToolBarAction): def __init__( self, - actions: Dict[str, ToolBarAction], - initial_action: str = None, - tooltip: str = None, + actions: Dict[str, IconAction], + initial_action: str | None = None, + tooltip: str | None = None, checkable: bool = True, default_state_checked: bool = False, parent=None, @@ -266,11 +339,11 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): target (QWidget): The target widget for the action. """ self.main_button = LongPressToolButton(toolbar) - self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.main_button.setCheckable(self.checkable) default_action = self.actions[self.current_key] self.main_button.setIcon(default_action.get_icon()) - self.main_button.setToolTip(default_action.tooltip) + self.main_button.setToolTip(default_action.tooltip or "") self.main_button.clicked.connect(self._trigger_current_action) menu = QMenu(self.main_button) for key, action_obj in self.actions.items(): @@ -368,11 +441,7 @@ class WidgetAction(ToolBarAction): """ def __init__( - self, - label: str | None = None, - widget: QWidget = None, - adjust_size: bool = True, - parent=None, + self, *, widget: QWidget, label: str | None = None, adjust_size: bool = True, parent=None ): super().__init__(icon_path=None, tooltip=label, checkable=False) self.label = label @@ -395,14 +464,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): if self.label is not None: label_widget = QLabel(text=f"{self.label}", parent=target) - label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + label_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + label_widget.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) layout.addWidget(label_widget) if isinstance(self.widget, QComboBox) and self.adjust_size: - self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) + self.widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.widget.setSizePolicy(size_policy) self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) @@ -411,7 +480,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addWidget(self.container) # Store the container as the action to allow toggling visibility. - self.action = self.container + self.action = self.container # type: ignore def cleanup(self): """ @@ -426,10 +495,86 @@ def cleanup(self): @staticmethod def calculate_minimum_width(combo_box: QComboBox) -> int: font_metrics = combo_box.fontMetrics() - max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) + max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) # type: ignore 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. @@ -440,16 +585,20 @@ class ExpandableMenuAction(ToolBarAction): icon_path (str, optional): The path to the icon file. Defaults to None. """ - def __init__(self, label: str, actions: dict, icon_path: str = None): + def __init__(self, label: str, actions: dict[str, IconAction], icon_path: str | None = None): super().__init__(icon_path, label) self.actions = actions + self._button_ref: weakref.ReferenceType[QToolButton] | None = None + self._menu_ref: weakref.ReferenceType[QMenu] | None = None def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): button = QToolButton(toolbar) + button.setObjectName("toolbarMenuButton") + button.setAutoRaise(True) if self.icon_path: button.setIcon(QIcon(self.icon_path)) button.setText(self.tooltip) - button.setPopupMode(QToolButton.InstantPopup) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) button.setStyleSheet( """ QToolButton { @@ -476,6 +625,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): menu.addAction(action) button.setMenu(menu) toolbar.addWidget(button) + self._button_ref = weakref.ref(button) + self._menu_ref = weakref.ref(menu) + + def get_toolbar_button(self) -> QToolButton | None: + return self._button_ref() if self._button_ref else None + + def get_menu(self) -> QMenu | None: + return self._menu_ref() if self._menu_ref else None class DeviceComboBoxAction(WidgetAction): @@ -522,3 +679,76 @@ def cleanup(self): self.combobox.close() self.combobox.deleteLater() return super().cleanup() + + +class TutorialAction(MaterialIconAction): + """ + Action for starting a guided tutorial/help tour. + + This action automatically initializes a GuidedTour instance and provides + methods to register widgets and start tours. + + Args: + main_window (QWidget): The main window widget for the guided tour overlay. + tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial". + parent (QWidget or None, optional): Parent widget for the underlying QAction. + """ + + def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None): + super().__init__( + icon_name="help", + tooltip=tooltip, + checkable=False, + filled=False, + color=None, + parent=parent, + ) + + from bec_widgets.utils.guided_tour import GuidedTour + + self.guided_help = GuidedTour(main_window) + self.main_window = main_window + + # Connect the action to start the tour + self.action.triggered.connect(self.start_tour) + + def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str: + """ + Register a widget for the guided tour. + + Args: + widget (QWidget): The widget to highlight during the tour. + text (str): The help text to display. + widget_name (str, optional): Optional name for the widget. + + Returns: + str: Unique ID for the registered widget. + """ + return self.guided_help.register_widget(widget=widget, text=text, title=widget_name) + + def start_tour(self): + """Start the guided tour with all registered widgets.""" + registered_widgets = self.guided_help.get_registered_widgets() + if registered_widgets: + # Create tour from all registered widgets + step_ids = list(registered_widgets.keys()) + if self.guided_help.create_tour(step_ids): + self.guided_help.start_tour() + else: + logger.warning("Failed to create guided tour") + else: + logger.warning("No widgets registered for guided tour") + + def has_registered_widgets(self) -> bool: + """Check if any widgets have been registered for the tour.""" + return len(self.guided_help.get_registered_widgets()) > 0 + + def clear_registered_widgets(self): + """Clear all registered widgets.""" + self.guided_help.clear_registrations() + + def cleanup(self): + """Clean up the guided help instance.""" + if hasattr(self, "guided_help"): + self.guided_help.stop_tour() + super().cleanup() diff --git a/bec_widgets/utils/toolbars/bundles.py b/bec_widgets/utils/toolbars/bundles.py index 36876995d..5c312c6bb 100644 --- a/bec_widgets/utils/toolbars/bundles.py +++ b/bec_widgets/utils/toolbars/bundles.py @@ -7,10 +7,17 @@ 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, ToolBarAction +from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction + +DEFAULT_SIZE = 400 +MAX_SIZE = 10_000_000 if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -195,6 +202,84 @@ def add_separator(self): """ 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. diff --git a/bec_widgets/utils/toolbars/connections.py b/bec_widgets/utils/toolbars/connections.py index 50b6a1e55..6986c6d35 100644 --- a/bec_widgets/utils/toolbars/connections.py +++ b/bec_widgets/utils/toolbars/connections.py @@ -1,18 +1,136 @@ 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 @@ -20,4 +138,6 @@ def disconnect(self): """ 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. """ diff --git a/bec_widgets/utils/toolbars/splitter.py b/bec_widgets/utils/toolbars/splitter.py new file mode 100644 index 000000000..91805fc16 --- /dev/null +++ b/bec_widgets/utils/toolbars/splitter.py @@ -0,0 +1,241 @@ +""" +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() diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index 21b3c7107..fc2ec7ad9 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -6,12 +6,21 @@ from typing import DefaultDict, Literal from bec_lib.logger import bec_logger -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QAction, QColor -from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget - -from bec_widgets.utils.colors import get_theme_name, set_theme -from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + 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.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection @@ -406,9 +415,18 @@ def toggle_action_visibility(self, action_id: str, visible: bool | None = None): def update_separators(self): """ - Hide separators that are adjacent to another separator or have no non-separator actions between them. + 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. """ + 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(): @@ -423,23 +441,32 @@ def update_separators(self): if toolbar_actions[j].isVisible(): next_visible = toolbar_actions[j] break - if (prev_visible is None or prev_visible.isSeparator()) and ( - next_visible is None or next_visible.isSeparator() + + # 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) ): action.setVisible(False) else: action.setVisible(True) - # Second pass: ensure no two visible separators are adjacent. + # Second pass: ensure no two visible separators are adjacent, and no separators next to splitters. prev = None for action in toolbar_actions: - if action.isVisible() and action.isSeparator(): - if prev and prev.isSeparator(): - action.setVisible(False) + 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 else: prev = action - else: - if action.isVisible(): - prev = action if not toolbar_actions: return @@ -481,21 +508,65 @@ def __init__(self): self.setWindowTitle("Toolbar / ToolbarBundle Demo") self.central_widget = QWidget() self.setCentralWidget(self.central_widget) - self.test_label = QLabel(text="This is a test label.") + self.test_label = QLabel(text="Drag the splitter (⋮) to resize!") 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( "base", PerformanceConnection(self.toolbar.components, self) ) - self.toolbar.show_bundles(["performance", "plot_export"]) + self.toolbar.components.add_safe( + "text", + MaterialIconAction( + "text_fields", + tooltip="Test Text Action", + checkable=True, + label_text="text", + 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.get_bundle("performance").add_action("save") + self.toolbar.get_bundle("performance").add_action("text") self.toolbar.refresh() + # Timer to disable and enable text button each 2s + self.timer = QTimer() + self.timer.timeout.connect(self.toggle_text_action) + self.timer.start(2000) + + def toggle_text_action(self): + text_action = self.toolbar.components.get_action("text") + if text_action.action.isEnabled(): + text_action.action.setEnabled(False) + else: + text_action.action.setEnabled(True) + def enable_fps_monitor(self, enabled: bool): """ Example method to enable or disable FPS monitoring. @@ -507,7 +578,7 @@ def enable_fps_monitor(self, enabled: bool): self.test_label.setText("FPS Monitor Disabled") app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") main_window = MainWindow() main_window.show() sys.exit(app.exec_()) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 22a754d9a..443eb814e 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -2,8 +2,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Type, TypeVar, cast import shiboken6 as shb +from bec_lib import bec_logger from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -21,6 +23,13 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils import BECConnector + +logger = bec_logger.logger + +TAncestor = TypeVar("TAncestor", bound=QWidget) + class WidgetHandler(ABC): """Abstract base class for all widget handlers.""" @@ -465,13 +474,19 @@ def _get_becwidget_ancestor(widget): """ from bec_widgets.utils import BECConnector + # Guard against deleted/invalid Qt wrappers if not shb.isValid(widget): return None - parent = widget.parent() + + # Retrieve first parent + parent = widget.parent() if hasattr(widget, "parent") else None + # Walk up, validating each step while parent is not None: + if not shb.isValid(parent): + return None if isinstance(parent, BECConnector): return parent - parent = parent.parent() + parent = parent.parent() if hasattr(parent, "parent") else None return None @staticmethod @@ -553,6 +568,70 @@ def import_config_from_dict(widget, config: dict, set_values: bool = False) -> N WidgetIO.set_value(child, value) WidgetHierarchy.import_config_from_dict(child, widget_config, set_values) + @staticmethod + def get_bec_connectors_from_parent(widget) -> list: + """ + Return all BECConnector instances whose closest BECConnector ancestor is the given widget, + including the widget itself if it is a BECConnector. + """ + from bec_widgets.utils import BECConnector + + connectors: list[BECConnector] = [] + if isinstance(widget, BECConnector): + connectors.append(widget) + for child in widget.findChildren(BECConnector): + if WidgetHierarchy._get_becwidget_ancestor(child) is widget: + connectors.append(child) + return connectors + + @staticmethod + def find_ancestor( + widget: QWidget | BECConnector, ancestor_class: Type[TAncestor] | str + ) -> TAncestor | None: + """ + Find the closest ancestor of the specified class (or class-name string). + + Args: + widget(QWidget): The starting widget. + ancestor_class(Type[TAncestor] | str): The ancestor class or class-name string to search for. + + Returns: + TAncestor | None: The closest ancestor of the specified class, or None if not found. + """ + if widget is None or not shb.isValid(widget): + return None + + try: + from bec_widgets.utils import BECConnector # local import to avoid cycles + + is_bec_target = False + if isinstance(ancestor_class, str): + is_bec_target = ancestor_class == "BECConnector" + elif isinstance(ancestor_class, type): + is_bec_target = issubclass(ancestor_class, BECConnector) + + if is_bec_target: + ancestor = WidgetHierarchy._get_becwidget_ancestor(widget) + return cast(TAncestor, ancestor) + except Exception as e: + logger.error(f"Error importing BECConnector: {e}") + + parent = widget.parent() if hasattr(widget, "parent") else None + while parent is not None: + if not shb.isValid(parent): + return None + try: + if isinstance(ancestor_class, str): + if parent.__class__.__name__ == ancestor_class: + return cast(TAncestor, parent) + else: + if isinstance(parent, ancestor_class): + return cast(TAncestor, parent) + except Exception as e: + logger.error(f"Error checking ancestor class: {e}") + parent = parent.parent() if hasattr(parent, "parent") else None + return None + # Example usage def hierarchy_example(): # pragma: no cover diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 9537097c2..13505087b 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations +import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -15,58 +17,99 @@ QWidget, ) +from bec_widgets.utils.widget_io import WidgetHierarchy + logger = bec_logger.logger +PROPERTY_TO_SKIP = [ + "palette", + "font", + "windowIcon", + "windowIconText", + "locale", + "styleSheet", + "updatesEnabled", + "objectName", + "visible", +] + class WidgetStateManager: """ - A class to manage the state of a widget by saving and loading the state to and from a INI file. + Manage saving and loading widget state to/from an INI file. Args: - widget(QWidget): The widget to manage the state for. + widget (QWidget): Root widget whose subtree will be serialized. + serialize_from_root (bool): When True, build group names relative to + this root and ignore parents above it. This keeps profiles portable + between different host window hierarchies. + root_id (str | None): Optional stable label to use for the root in + the settings key path. When omitted and `serialize_from_root` is + True, the class name of `widget` is used, falling back to its + objectName and finally to "root". """ - def __init__(self, widget): + def __init__(self, widget, *, serialize_from_root: bool = False, root_id: str | None = None): self.widget = widget + self._serialize_from_root = bool(serialize_from_root) + self._root_id = root_id - def save_state(self, filename: str = None): + def save_state(self, filename: str | None = None, settings: QSettings | None = None): """ Save the state of the widget to an INI file. Args: filename(str): The filename to save the state to. + settings(QSettings): Optional QSettings object to save the state to. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getSaveFileName( self.widget, "Save Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._save_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, save the state to the provided QSettings object + self._save_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def load_state(self, filename: str = None): + def load_state(self, filename: str | None = None, settings: QSettings | None = None): """ Load the state of the widget from an INI file. Args: filename(str): The filename to load the state from. + settings(QSettings): Optional QSettings object to load the state from. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getOpenFileName( self.widget, "Load Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._load_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, load the state from the provided QSettings object + self._load_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): + def _save_widget_state_qsettings( + self, widget: QWidget, settings: QSettings, recursive: bool = True + ): """ Save the state of the widget to QSettings. Args: widget(QWidget): The widget to save the state for. settings(QSettings): The QSettings object to save the state to. + recursive(bool): Whether to recursively save the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -76,34 +119,56 @@ def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + if ( - name == "objectName" + name in PROPERTY_TO_SKIP or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter ): continue + value = widget.property(name) + if isinstance(value, QIcon): + continue settings.setValue(name, value) + settings.endGroup() # Recursively process children (only if they aren't skipped) - for child in widget.children(): + if not recursive: + return + + direct_children = widget.children() + bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget) + all_children = list( + set(direct_children) | set(bec_connector_children) + ) # to avoid duplicates + for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): - self._save_widget_state_qsettings(child, settings) + self._save_widget_state_qsettings(child, settings, False) + logger.info(f"Saved state for widget '{widget_name}'") - def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): + def _load_widget_state_qsettings( + self, widget: QWidget, settings: QSettings, recursive: bool = True + ): """ Load the state of the widget from QSettings. Args: widget(QWidget): The widget to load the state for. settings(QSettings): The QSettings object to load the state from. + recursive(bool): Whether to recursively load the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -113,37 +178,76 @@ def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + if name in PROPERTY_TO_SKIP: + continue if settings.contains(name): value = settings.value(name) widget.setProperty(name, value) settings.endGroup() + if not recursive: + return # Recursively process children (only if they aren't skipped) - for child in widget.children(): + direct_children = widget.children() + bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget) + all_children = list( + set(direct_children) | set(bec_connector_children) + ) # to avoid duplicates + for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): - self._load_widget_state_qsettings(child, settings) + self._load_widget_state_qsettings(child, settings, False) - def _get_full_widget_name(self, widget: QWidget): + def _get_full_widget_name(self, widget: QWidget) -> str: """ - Get the full name of the widget including its parent names. + Build a group key for *widget*. - Args: - widget(QWidget): The widget to get the full name for. + When `serialize_from_root` is False (default), this preserves the original + behavior and walks all parents up to the top-level widget. - Returns: - str: The full name of the widget. + When `serialize_from_root` is True, the key is built relative to + `self.widget` and parents above the managed root are ignored. The first + path segment is either `root_id` (when provided) or a stable label derived + from the root widget (class name, then objectName, then "root"). + + Args: + widget (QWidget): The widget to build the key for. """ - name = widget.objectName() - parent = widget.parent() - while parent: - obj_name = parent.objectName() or parent.metaObject().className() - name = obj_name + "." + name - parent = parent.parent() - return name + # Backwards-compatible behavior: include the entire parent chain. + if not getattr(self, "_serialize_from_root", False): + name = widget.objectName() + parent = widget.parent() + while parent: + obj_name = parent.objectName() or parent.metaObject().className() + name = obj_name + "." + name + parent = parent.parent() + return name + + parts: list[str] = [] + current: QWidget | None = widget + + while current is not None: + if current is self.widget: + # Reached the serialization root. + root_label = self._root_id + if not root_label: + meta = current.metaObject() if hasattr(current, "metaObject") else None + class_name = meta.className() if meta is not None else "" + root_label = class_name or current.objectName() or "root" + parts.append(str(root_label)) + break + + obj_name = current.objectName() or current.metaObject().className() + parts.append(obj_name) + current = current.parent() + + parts.reverse() + return ".".join(parts) class ExampleApp(QWidget): # pragma: no cover: diff --git a/bec_widgets/widgets/containers/auto_update/auto_updates.py b/bec_widgets/widgets/containers/auto_update/auto_updates.py index ed4777269..e3c7a7092 100644 --- a/bec_widgets/widgets/containers/auto_update/auto_updates.py +++ b/bec_widgets/widgets/containers/auto_update/auto_updates.py @@ -7,12 +7,12 @@ from bec_lib.messages import ScanStatusMessage from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.containers.dock_area.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 @@ class AutoUpdates(BECMainWindow): - _default_dock: BECDock + _default_dock: CDockWidget | None USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"] RPC = True PLUGIN = False @@ -37,7 +37,12 @@ def __init__( ): super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) - self.dock_area = BECDockArea(parent=self, object_name="dock_area") + self.dock_area = BECDockArea( + parent=self, + object_name="dock_area", + enable_profile_management=False, + restore_initial_profile=False, + ) self.setCentralWidget(self.dock_area) self._auto_update_selected_device: str | None = None @@ -106,9 +111,11 @@ def start_default_dock(self): """ Create a default dock for the auto updates. """ + self.dock_area.delete_all() self.dock_name = "update_dock" - self._default_dock = self.dock_area.new(self.dock_name) - self.current_widget = self._default_dock.new("Waveform") + self.current_widget = self.dock_area.new("Waveform") + docks = self.dock_area.dock_list() + self._default_dock = docks[0] if docks else None @overload def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ... @@ -138,16 +145,18 @@ def set_dock_to_widget( Returns: BECWidget: The widget that was set. """ - if self._default_dock is None or self.current_widget is None: + if 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 not self.current_widget.__class__.__name__ == widget: - self._default_dock.delete(self.current_widget.object_name) - self.current_widget = self._default_dock.new(widget) + 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 return self.current_widget def get_selected_device( diff --git a/bec_widgets/widgets/containers/dock/__init__.py b/bec_widgets/widgets/containers/dock/__init__.py deleted file mode 100644 index d83dbe5f7..000000000 --- a/bec_widgets/widgets/containers/dock/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .dock import BECDock -from .dock_area import BECDockArea diff --git a/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject b/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject deleted file mode 100644 index e12ce0314..000000000 --- a/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['dock_area.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/containers/dock/dock.py b/bec_widgets/widgets/containers/dock/dock.py deleted file mode 100644 index 07f81a45c..000000000 --- a/bec_widgets/widgets/containers/dock/dock.py +++ /dev/null @@ -1,440 +0,0 @@ -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_()) diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py deleted file mode 100644 index ca6a698b1..000000000 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ /dev/null @@ -1,633 +0,0 @@ -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 set_theme - - app = QApplication([]) - set_theme("auto") - 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_()) diff --git a/bec_widgets/widgets/containers/dock_area/__init__.py b/bec_widgets/widgets/containers/dock_area/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py new file mode 100644 index 000000000..5111619b8 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py @@ -0,0 +1,1555 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +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.QtGui import QIcon +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget +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.property_editor import PropertyEditor +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.qt_ads import ( + CDockAreaWidget, + CDockManager, + CDockSplitter, + CDockWidget, +) + +logger = bec_logger.logger + + +class DockSettingsDialog(QDialog): + """Generic settings editor shown from dock title bar actions.""" + + def __init__(self, parent: QWidget, target: QWidget): + super().__init__(parent) + self.setWindowTitle("Dock Settings") + self.setModal(True) + layout = QVBoxLayout(self) + self.prop_editor = PropertyEditor(target, self, show_only_bec=True) + layout.addWidget(self.prop_editor) + + +class DockAreaWidget(BECWidget, QWidget): + """ + Lightweight dock area that exposes the core Qt ADS docking helpers without any + of the toolbar or workspace management features that the advanced variant offers. + """ + + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "dock_map", + "dock_list", + "widget_map", + "widget_list", + "attach_all", + "delete_all", + "delete", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", + "set_central_dock", + ] + + @dataclass + class DockCreationSpec: + widget: QWidget + closable: bool = True + floatable: bool = True + movable: bool = True + start_floating: bool = False + floating_state: Mapping[str, Any] | None = None + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea + on_close: Callable[[CDockWidget, QWidget], None] | None = None + tab_with: CDockWidget | None = None + relative_to: CDockWidget | None = None + title_visible: bool | None = None + title_buttons: Mapping[QtAds.ads.TitleBarButton, bool] | None = None + show_settings_action: bool | None = False + dock_preferences: Mapping[str, Any] | None = None + promote_central: bool = False + dock_icon: QIcon | None = None + apply_widget_icon: bool = True + + def __init__( + self, + parent: QWidget | None = None, + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + title: str = "Dock Area", + variant: Literal["cards", "compact"] = "cards", + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + + # Set variant property for styling + + if title: + self.setWindowTitle(title) + + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.setProperty("variant", variant) + + self._locked = False + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + self._root_layout.addWidget(self.dock_manager, 1) + + ################################################################################ + # Dock Utility Helpers + ################################################################################ + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Translate a direction string into a Qt ADS dock widget area.""" + direction = (where or self._default_add_direction or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(direction, QtAds.DockWidgetArea.RightDockWidgetArea) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + """Hook for subclasses to customise the dock before it is shown.""" + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) or {} + show_settings = prefs.get("show_settings_action") + if show_settings: + self._install_dock_settings_action(dock, widget) + + def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: + """Attach a dock-level settings action if available.""" + if getattr(dock, "setting_action", None) is not None: + return + + action = MaterialIconAction( + icon_name="settings", tooltip="Dock settings", filled=True, parent=self + ).action + action.setObjectName("dockSettingsAction") + action.setToolTip("Dock settings") + action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + + existing = list(dock.titleBarActions()) + existing.append(action) + dock.setTitleBarActions(existing) + dock.setting_action = action + + def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: + """Launch the property editor dialog for the dock's widget.""" + dlg = DockSettingsDialog(self, widget) + dlg.resize(600, 600) + dlg.exec() + + ################################################################################ + # Dock Lifecycle + ################################################################################ + + def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None: + """Default dock close routine used when no custom handler is provided.""" + widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + + def close_dock(self, dock: CDockWidget, widget: QWidget | None = None) -> None: + """ + Helper for custom close handlers to invoke the default close behaviour. + + Args: + dock: Dock widget to close. + widget: Optional widget contained in the dock; resolved automatically when not given. + """ + target_widget = widget or dock.widget() + if target_widget is None: + return + self._default_close_handler(dock, target_widget) + + def _wrap_close_candidate( + self, candidate: Callable, widget: QWidget + ) -> Callable[[CDockWidget], None]: + """ + Wrap a user-provided close handler to adapt its signature. + + Args: + candidate(Callable): User-provided close handler. + widget(QWidget): Widget contained in the dock. + + Returns: + Callable[[CDockWidget], None]: Wrapped close handler. + """ + try: + sig = inspect.signature(candidate) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values() + ) + positional_params = [ + p + for p in sig.parameters.values() + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + except (ValueError, TypeError): + accepts_varargs = True + positional_params = [] + + positional_count = len(positional_params) + + def invoke(dock: CDockWidget) -> None: + try: + if accepts_varargs or positional_count >= 2: + candidate(dock, widget) + elif positional_count == 1: + candidate(dock) + else: + candidate() + except TypeError: + # Best effort fallback in case the signature inspection was misleading. + candidate(dock, widget) + + return invoke + + def _resolve_close_handler( + self, widget: QWidget, on_close: Callable[[CDockWidget, QWidget], None] | None = None + ) -> Callable[[CDockWidget], None]: + """ + Determine which close handler to use for a dock. + Priority: + 1. Explicit `on_close` callable passed to `new`. + 2. Widget attribute `handle_dock_close` or `on_dock_close` if callable. + 3. Default close handler. + + Args: + widget(QWidget): The widget contained in the dock. + on_close(Callable[[CDockWidget, QWidget], None] | None): Explicit close handler. + + Returns: + Callable[[CDockWidget], None]: Resolved close handler. + """ + + candidate = on_close + if candidate is None: + candidate = getattr(widget, "handle_dock_close", None) + if candidate is None: + candidate = getattr(widget, "on_dock_close", None) + + if callable(candidate): + return self._wrap_close_candidate(candidate, widget) + + return lambda dock: self._default_close_handler(dock, widget) + + def _make_dock( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool = True, + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, + start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | None = None, + relative_to: CDockWidget | None = None, + dock_preferences: Mapping[str, Any] | None = None, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + ) -> CDockWidget: + """ + Create and add a new dock widget to the area. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + area(QtAds.DockWidgetArea): Target dock area. + start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional geometry metadata to apply when floating. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | None): Optional dock to tab with. + relative_to(CDockWidget | None): Optional dock to position relative to. + dock_preferences(Mapping[str, Any] | None): Appearance preferences to apply. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + CDockWidget: Created dock widget. + """ + if not widget.objectName(): + widget.setObjectName(widget.__class__.__name__) + + if tab_with is not None and relative_to is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + 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) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, closable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, floatable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, movable) + + self._customize_dock(dock, widget) + resolved_icon = self._resolve_dock_icon(widget, dock_icon, apply_widget_icon) + + close_handler = self._resolve_close_handler(widget, on_close) + + def on_widget_destroyed(): + if not isValid(dock): + return + dock.closeDockWidget() + dock.deleteDockWidget() + + dock.closeRequested.connect(lambda: close_handler(dock)) + if hasattr(widget, "widget_removed"): + widget.widget_removed.connect(on_widget_destroyed) + + dock.setMinimumSizeHintMode( + CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize + ) + dock_area_widget = None + if tab_with is not None: + if not isValid(tab_with): + raise ValueError("Tab target dock widget is not valid anymore.") + dock_area_widget = tab_with.dockAreaWidget() + + if dock_area_widget is not None: + self.dock_manager.addDockWidgetTabToArea(dock, dock_area_widget) + else: + target_area_widget = None + if relative_to is not None: + if not isValid(relative_to): + raise ValueError("Relative target dock widget is not valid anymore.") + target_area_widget = relative_to.dockAreaWidget() + self.dock_manager.addDockWidget(area, dock, target_area_widget) + + if start_floating and tab_with is None and not promote_central: + dock.setFloating() + if floating_state: + self._apply_floating_state_to_dock(dock, floating_state) + if resolved_icon is not None: + dock.setIcon(resolved_icon) + return dock + + def _delete_dock(self, dock: CDockWidget) -> None: + widget = dock.widget() + if widget and isValid(widget): + widget.close() + widget.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + def _resolve_dock_reference( + self, ref: CDockWidget | QWidget | str | None, *, allow_none: bool = True + ) -> CDockWidget | None: + """ + Resolve a dock reference from various input types. + + Args: + ref(CDockWidget | QWidget | str | None): Dock reference. + allow_none(bool): Whether to allow None as a valid return value. + + Returns: + CDockWidget | None: Resolved dock widget or None. + """ + if ref is None: + if allow_none: + return None + raise ValueError("Dock reference cannot be None.") + if isinstance(ref, CDockWidget): + if not isValid(ref): + raise ValueError("Dock widget reference is not valid anymore.") + return ref + if isinstance(ref, QWidget): + for dock in self.dock_list(): + if dock.widget() is ref: + return dock + raise ValueError("Widget reference is not associated with any dock in this area.") + if isinstance(ref, str): + dock_map = self.dock_map() + dock = dock_map.get(ref) + if dock is None: + raise ValueError(f"No dock found with objectName '{ref}'.") + return dock + raise TypeError( + "Dock reference must be a CDockWidget, QWidget, object name string, or None." + ) + + ################################################################################ + # Splitter Handling + ################################################################################ + + def _resolve_dock_icon( + self, widget: QWidget, dock_icon: QIcon | None, apply_widget_icon: bool + ) -> QIcon | None: + """ + Choose an icon for the dock: prefer an explicitly provided one, otherwise + fall back to the widget's `ICON_NAME` (material icons) when available. + + Args: + widget(QWidget): The widget to dock. + dock_icon(QIcon | None): Explicit icon to use for the dock. + + Returns: + QIcon | None: Resolved dock icon, or None if not available. + """ + + if dock_icon is not None: + return dock_icon + if not apply_widget_icon: + return None + icon_name = getattr(widget, "ICON_NAME", None) + if not icon_name: + return None + try: + return material_icon(icon_name, size=(24, 24), convert_to_pixmap=False) + except Exception: + return None + + def _build_creation_spec( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool, + start_floating: bool, + floating_state: Mapping[str, object] | None, + where: Literal["left", "right", "top", "bottom"] | None, + on_close: Callable[[CDockWidget, QWidget], None] | None, + tab_with: CDockWidget | QWidget | str | None, + relative_to: CDockWidget | QWidget | str | None, + show_title_bar: bool | None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None, + show_settings_action: bool | None, + promote_central: bool, + dock_icon: QIcon | None, + apply_widget_icon: bool, + ) -> DockCreationSpec: + """ + Normalize and validate dock creation parameters into a spec object. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional floating geometry metadata. + where(Literal["left", "right", "top", "bottom"] | None): Target dock area. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. + relative_to(CDockWidget | QWidget | str | None): Optional dock to position relative to. + show_title_bar(bool | None): Whether to show the dock title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Title bar buttons to show/hide. + show_settings_action(bool | None): Whether to show the dock settings action. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + DockCreationSpec: Normalized dock creation specification. + + """ + normalized_buttons = self._normalize_title_buttons(title_buttons) + resolved_tab = self._resolve_dock_reference(tab_with) + resolved_relative = self._resolve_dock_reference(relative_to) + + if resolved_tab is not None and resolved_relative is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + target_area = self._area_from_where(where) + if resolved_relative is not None and where is None: + inferred = self.dock_manager.dockWidgetArea(resolved_relative) + if inferred in ( + QtAds.DockWidgetArea.InvalidDockWidgetArea, + QtAds.DockWidgetArea.NoDockWidgetArea, + ): + inferred = self._area_from_where(None) + target_area = inferred + + dock_preferences = { + "show_title_bar": show_title_bar, + "title_buttons": normalized_buttons if normalized_buttons else None, + "show_settings_action": show_settings_action, + } + dock_preferences = {k: v for k, v in dock_preferences.items() if v is not None} + + return self.DockCreationSpec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + floating_state=floating_state, + area=target_area, + on_close=on_close, + tab_with=resolved_tab, + relative_to=resolved_relative, + title_visible=show_title_bar, + title_buttons=normalized_buttons if normalized_buttons else None, + show_settings_action=show_settings_action, + dock_preferences=dock_preferences or None, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: + """ + Create a dock from a normalized spec and apply preferences. + + Args: + spec(DockCreationSpec): Dock creation specification. + + Returns: + CDockWidget: Created dock widget. + """ + dock = self._make_dock( + spec.widget, + closable=spec.closable, + floatable=spec.floatable, + movable=spec.movable, + floating_state=spec.floating_state, + area=spec.area, + start_floating=spec.start_floating, + on_close=spec.on_close, + tab_with=spec.tab_with, + relative_to=spec.relative_to, + dock_preferences=spec.dock_preferences, + promote_central=spec.promote_central, + dock_icon=spec.dock_icon, + apply_widget_icon=spec.apply_widget_icon, + ) + self.dock_manager.setFocus() + self._apply_dock_preferences(dock) + if spec.promote_central: + self.set_central_dock(dock) + return dock + + def _coerce_weights( + self, + weights: Sequence[float] | Mapping[int | str, float] | None, + count: int, + orientation: Qt.Orientation, + ) -> list[float] | None: + """ + Normalize weight specs into a list matching splitter child count. + + Args: + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + count(int): Number of splitter children. + orientation(Qt.Orientation): Splitter orientation. + + Returns: + list[float] | None: Normalized weight list, or None if invalid. + """ + if weights is None or count <= 0: + return None + + result: list[float] + if isinstance(weights, (list, tuple)): + result = [float(v) for v in weights[:count]] + elif isinstance(weights, Mapping): + default = float(weights.get("default", 1.0)) + result = [default] * count + + alias: dict[str, int] = {} + if count >= 1: + alias["first"] = 0 + alias["start"] = 0 + if count >= 2: + alias["last"] = count - 1 + alias["end"] = count - 1 + if orientation == Qt.Orientation.Horizontal: + alias["left"] = 0 + alias["right"] = count - 1 + if count >= 3: + alias["center"] = count // 2 + alias["middle"] = count // 2 + else: + alias["top"] = 0 + alias["bottom"] = count - 1 + + for key, value in weights.items(): + if key == "default": + continue + idx: int | None = None + if isinstance(key, int): + idx = key + elif isinstance(key, str): + lowered = key.lower() + if lowered in alias: + idx = alias[lowered] + elif lowered.startswith("col"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + elif lowered.startswith("row"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + if idx is not None and 0 <= idx < count: + result[idx] = float(value) + else: + return None + + if len(result) < count: + result += [1.0] * (count - len(result)) + result = result[:count] + if all(v <= 0 for v in result): + result = [1.0] * count + return result + + def _schedule_splitter_weights( + self, + splitter: QtAds.CDockSplitter, + weights: Sequence[float] | Mapping[int | str, float] | None, + ) -> None: + """ + Apply weight ratios to a splitter once geometry is available. + + Args: + splitter(QtAds.CDockSplitter): Target splitter. + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + """ + if splitter is None or weights is None: + return + + ratios = self._coerce_weights(weights, splitter.count(), splitter.orientation()) + if not ratios: + return + + def apply(): + count = splitter.count() + if count != len(ratios): + return + + orientation = splitter.orientation() + total_px = ( + splitter.width() if orientation == Qt.Orientation.Horizontal else splitter.height() + ) + if total_px <= count: + QTimer.singleShot(0, apply) + return + + total = sum(ratios) + if total <= 0: + return + sizes = [max(1, int(round(total_px * (r / total)))) for r in ratios] + diff = total_px - sum(sizes) + if diff: + idx = max(range(count), key=lambda i: ratios[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, weight in enumerate(ratios): + splitter.setStretchFactor(i, max(1, int(round(weight * 100)))) + + QTimer.singleShot(0, apply) + + def _normalize_override_keys( + self, + overrides: Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]], + ) -> dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + """ + Normalize various key types into tuple paths. + + Args: + overrides(Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]]): + Original overrides mapping. + + Returns: + dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + Normalized overrides mapping. + """ + normalized: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]] = {} + for key, value in overrides.items(): + path: tuple[int, ...] | None = None + if isinstance(key, int): + path = (key,) + elif isinstance(key, (list, tuple)): + try: + path = tuple(int(k) for k in key) + except ValueError: + continue + elif isinstance(key, str): + cleaned = key.replace(" ", "").replace(".", "/") + if cleaned in ("", "/"): + path = () + else: + parts = [p for p in cleaned.split("/") if p] + try: + path = tuple(int(p) for p in parts) + except ValueError: + continue + if path is not None: + normalized[path] = value + return normalized + + def _apply_splitter_tree( + self, + splitter: QtAds.CDockSplitter, + path: tuple[int, ...], + horizontal: Sequence[float] | Mapping[int | str, float] | None, + vertical: Sequence[float] | Mapping[int | str, float] | None, + overrides: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]], + ) -> None: + """Traverse splitter hierarchy and apply ratios.""" + orientation = splitter.orientation() + base_weights = horizontal if orientation == Qt.Orientation.Horizontal else vertical + + override = None + if overrides: + if path in overrides: + override = overrides[path] + elif len(path) >= 1: + key = (path[-1],) + if key in overrides: + override = overrides[key] + + self._schedule_splitter_weights(splitter, override or base_weights) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, QtAds.CDockSplitter): + self._apply_splitter_tree(child, path + (idx,), horizontal, vertical, overrides) + + ################################################################################ + # Layout Inspection + ################################################################################ + + def _collect_splitter_info( + self, + splitter: CDockSplitter, + path: tuple[int, ...], + results: list[dict[str, Any]], + container_index: int, + ) -> None: + orientation = ( + "horizontal" if splitter.orientation() == Qt.Orientation.Horizontal else "vertical" + ) + entry: dict[str, Any] = { + "container": container_index, + "path": path, + "orientation": orientation, + "children": [], + } + results.append(entry) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, CDockSplitter): + entry["children"].append({"index": idx, "type": "splitter"}) + self._collect_splitter_info(child, path + (idx,), results, container_index) + elif isinstance(child, CDockAreaWidget): + docks = [dock.objectName() for dock in child.dockWidgets()] + entry["children"].append({"index": idx, "type": "dock_area", "docks": docks}) + elif isinstance(child, CDockWidget): + entry["children"].append({"index": idx, "type": "dock", "name": child.objectName()}) + else: + entry["children"].append({"index": idx, "type": child.__class__.__name__}) + + def describe_layout(self) -> list[dict[str, Any]]: + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + info: list[dict[str, Any]] = [] + for container_index, container in enumerate(self.dock_manager.dockContainers()): + splitter = container.rootSplitter() + if splitter is None: + continue + self._collect_splitter_info(splitter, (), info, container_index) + return info + + def print_layout_structure(self) -> None: + """Pretty-print the current splitter paths to stdout.""" + for entry in self.describe_layout(): + children_desc = [] + for child in entry["children"]: + if child["type"] == "dock_area": + children_desc.append( + f"{child['index']}:dock_area[{', '.join(child['docks']) or '-'}]" + ) + elif child["type"] == "dock": + children_desc.append(f"{child['index']}:dock({child['name']})") + else: + children_desc.append(f"{child['index']}:{child['type']}") + summary = ", ".join(children_desc) + print( + f"container={entry['container']} path={entry['path']} " + f"orientation={entry['orientation']} -> [{summary}]" + ) + + ################################################################################ + # State Persistence + ################################################################################ + + @staticmethod + def _coerce_byte_array(value: Any) -> QByteArray | None: + """Best-effort conversion of arbitrary values into a QByteArray.""" + if isinstance(value, QByteArray): + return QByteArray(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return QByteArray(bytes(value)) + return None + + @staticmethod + def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[str, str | None]: + """ + Merge caller overrides with sensible defaults. + + Only `geom`, `state`, and `ads_state` are recognised. Missing entries default to: + geom -> "dock_area/geometry" + state -> None (skip writing legacy main window state) + ads_state -> "dock_area/docking_state" + """ + defaults: dict[str, str | None] = { + "geom": "dock_area/geometry", + "state": None, + "ads_state": "dock_area/docking_state", + } + if overrides: + for key, value in overrides.items(): + if key in defaults: + defaults[key] = value + return defaults + + def _select_screen_for_entry( + self, entry: Mapping[str, object], container: QtAds.CFloatingDockContainer | None + ): + """ + Pick the best target screen for a saved floating container. + + Args: + entry(Mapping[str, object]): Floating window entry. + container(QtAds.CFloatingDockContainer | None): Optional container instance. + """ + screens = QApplication.screens() or [] + try: + name = entry.get("screen_name") or "" + except Exception as exc: + logger.warning(f"Invalid screen_name in floating window entry: {exc}") + name = "" + if name: + for screen in screens: + try: + if screen.name() == name: + return screen + except Exception as exc: + logger.warning(f"Error checking screen name '{name}': {exc}") + continue + if container is not None and hasattr(container, "screen"): + screen = container.screen() + if screen is not None: + return screen + return screens[0] if screens else None + + def _apply_saved_floating_geometry( + self, container: QtAds.CFloatingDockContainer, entry: Mapping[str, object] + ) -> None: + """ + Resize/move a floating container using saved geometry information. + + Args: + container(QtAds.CFloatingDockContainer): Target floating container. + entry(Mapping[str, object]): Floating window entry. + """ + abs_geom = entry.get("absolute") if isinstance(entry, Mapping) else None + if isinstance(abs_geom, Mapping): + try: + x = int(abs_geom.get("x")) + y = int(abs_geom.get("y")) + width = int(abs_geom.get("w")) + height = int(abs_geom.get("h")) + except Exception as exc: + logger.warning(f"Invalid absolute geometry in floating window entry: {exc}") + else: + if width > 0 and height > 0: + container.setGeometry(x, y, max(width, 50), max(height, 50)) + return + + rel = entry.get("relative") if isinstance(entry, Mapping) else None + if not isinstance(rel, Mapping): + return + try: + x_ratio = float(rel.get("x")) + y_ratio = float(rel.get("y")) + w_ratio = float(rel.get("w")) + h_ratio = float(rel.get("h")) + except Exception as exc: + logger.warning(f"Invalid relative geometry in floating window entry: {exc}") + return + + screen = self._select_screen_for_entry(entry, container) + if screen is None: + return + geom = screen.availableGeometry() + screen_w = geom.width() + screen_h = geom.height() + if screen_w <= 0 or screen_h <= 0: + return + + min_w = 120 + min_h = 80 + width = max(min_w, int(round(screen_w * max(w_ratio, 0.05)))) + height = max(min_h, int(round(screen_h * max(h_ratio, 0.05)))) + width = min(width, screen_w) + height = min(height, screen_h) + + x = geom.left() + int(round(screen_w * x_ratio)) + y = geom.top() + int(round(screen_h * y_ratio)) + x = max(geom.left(), min(x, geom.left() + screen_w - width)) + y = max(geom.top(), min(y, geom.top() + screen_h - height)) + + container.setGeometry(x, y, width, height) + + def _apply_floating_state_to_dock( + self, dock: CDockWidget, state: Mapping[str, object], *, attempt: int = 0 + ) -> None: + """ + Apply saved floating geometry to a dock once its container exists. + + Args: + dock(CDockWidget): Target dock widget. + state(Mapping[str, object]): Saved floating state. + attempt(int): Current attempt count for retries. + """ + if state is None: + return + + def schedule(next_attempt: int): + QTimer.singleShot( + 50, lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt) + ) + + container = dock.floatingDockContainer() + if container is None: + if attempt < 10: + schedule(attempt + 1) + return + entry = { + "relative": state.get("relative") if isinstance(state, Mapping) else None, + "absolute": state.get("absolute") if isinstance(state, Mapping) else None, + "screen_name": state.get("screen_name") if isinstance(state, Mapping) else None, + } + self._apply_saved_floating_geometry(container, entry) + + def save_to_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Persist the current dock layout into an existing `QSettings` instance. + + Args: + settings(QSettings): Target QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + settings.setValue(geom_key, self.saveGeometry()) + + legacy_state_key = resolved.get("state") + if legacy_state_key: + settings.setValue(legacy_state_key, b"") + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + settings.setValue(ads_state_key, self.dock_manager.saveState()) + + if include_perspectives: + name = perspective_name or self.windowTitle() + if name: + self.dock_manager.addPerspective(name) + self.dock_manager.savePerspectives(settings) + + def save_to_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Convenience wrapper around `save_to_settings` that opens a temporary QSettings. + + Args: + path(str): File path to save the settings to. + format(QSettings.Format): File format to use. + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + settings = QSettings(path, format) + self.save_to_settings( + settings, + keys=keys, + include_perspectives=include_perspectives, + perspective_name=perspective_name, + ) + settings.sync() + + def load_from_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Restore the dock layout from a `QSettings` instance previously populated by `save_to_settings`. + + Args: + settings(QSettings): Source QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + restore_perspectives(bool): When True, restore Qt ADS perspectives alongside the layout. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + geom_value = settings.value(geom_key) + geom_bytes = self._coerce_byte_array(geom_value) + if geom_bytes is not None: + self.restoreGeometry(geom_bytes) + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + dock_state = settings.value(ads_state_key) + dock_bytes = self._coerce_byte_array(dock_state) + if dock_bytes is not None: + self.dock_manager.restoreState(dock_bytes) + + if restore_perspectives: + self.dock_manager.loadPerspectives(settings) + + def load_from_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Convenience wrapper around `load_from_settings` that reads from a file path. + """ + settings = QSettings(path, format) + self.load_from_settings(settings, keys=keys, restore_perspectives=restore_perspectives) + + def set_layout_ratios( + self, + *, + horizontal: Sequence[float] | Mapping[int | str, float] | None = None, + vertical: Sequence[float] | Mapping[int | str, float] | None = None, + splitter_overrides: ( + Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None + ) = None, + ) -> None: + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + overrides = self._normalize_override_keys(splitter_overrides) if splitter_overrides else {} + + for container in self.dock_manager.dockContainers(): + splitter = container.rootSplitter() + if splitter is None: + continue + self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) + + @staticmethod + def _title_bar_button_enum(name: str) -> QtAds.ads.TitleBarButton | None: + """Translate a user-friendly button name into an ADS TitleBarButton enum.""" + normalized = (name or "").lower().replace("-", "_").replace(" ", "_") + mapping: dict[str, QtAds.ads.TitleBarButton] = { + "menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "float": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "detach": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "close": QtAds.ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": QtAds.ads.TitleBarButton.TitleBarButtonMinimize, + } + return mapping.get(normalized) + + def _normalize_title_buttons( + self, + spec: ( + Mapping[str | QtAds.ads.TitleBarButton, bool] + | Sequence[str | QtAds.ads.TitleBarButton] + | str + | QtAds.ads.TitleBarButton + | None + ), + ) -> dict[QtAds.ads.TitleBarButton, bool]: + """Normalize button visibility specifications into an enum mapping.""" + if spec is None: + return {} + + result: dict[QtAds.ads.TitleBarButton, bool] = {} + if isinstance(spec, Mapping): + iterator = spec.items() + else: + if isinstance(spec, str): + spec = [spec] + iterator = ((name, False) for name in spec) + + for name, visible in iterator: + if isinstance(name, QtAds.ads.TitleBarButton): + enum = name + else: + enum = self._title_bar_button_enum(str(name)) + if enum is None: + continue + result[enum] = bool(visible) + return result + + def _apply_dock_preferences(self, dock: CDockWidget) -> None: + """ + Apply deferred appearance preferences to a dock once it has been created. + + Args: + dock(CDockWidget): Target dock widget. + """ + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) + + def apply(): + title_bar = None + area_widget = dock.dockAreaWidget() + if area_widget is not None and hasattr(area_widget, "titleBar"): + title_bar = area_widget.titleBar() + + show_title_bar = prefs.get("show_title_bar") + if title_bar is not None and show_title_bar is not None: + title_bar.setVisible(bool(show_title_bar)) + + button_prefs = prefs.get("title_buttons") or {} + if title_bar is not None and button_prefs: + for enum, visible in button_prefs.items(): + try: + button = title_bar.button(enum) + except Exception: # pragma: no cover - defensive against ADS API changes + button = None + if button is not None: + button.setVisible(bool(visible)) + + apply() + + def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None: + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + resolved = self._resolve_dock_reference(dock, allow_none=False) + self.dock_manager.setCentralWidget(resolved) + self._apply_dock_preferences(resolved) + + ################################################################################ + # Public API + ################################################################################ + + @SafeSlot(popup_error=True) + def new( + self, + widget: QWidget | str, + *, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, + 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 = False, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + object_name: str | None = None, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + 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. + floating_state(Mapping | None): Optional floating geometry metadata to apply when 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). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + 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. + return_dock(bool): When True, return the created dock instead of the widget. + 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 `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``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + 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. + """ + if isinstance(widget, str): + if return_dock: + raise ValueError( + "return_dock=True is not supported when creating widgets by type name." + ) + widget = cast( + BECWidget, + widget_handler.create_widget( + widget_type=widget, parent=self, object_name=object_name, **widget_kwargs + ), + ) + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + floating_state=floating_state, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + self._create_dock_from_spec(spec) + return widget + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + floating_state=floating_state, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + 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 {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 self._iter_all_docks() + + 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()} + + 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): + """Re-attach floating docks back into the dock manager.""" + for container in self.dock_manager.floatingWidgets(): + docks = container.dockWidgets() + if not docks: + continue + target = docks[0] + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) + for dock in docks[1:]: + self.dock_manager.addDockWidgetTab( + 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(): + self._delete_dock(dock) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton + + from bec_widgets.utils.colors import apply_theme + + class CustomCloseWidget(QWidget): + """Example widget showcasing custom close handling via handle_dock_close.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("CustomCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom close handler – tabbed with Column 1 / Row 1.\n" + "Close this dock to see the stdout cleanup message.", + self, + ) + ) + btn = QPushButton("Click me before closing", self) + layout.addWidget(btn) + + def handle_dock_close(self, dock: CDockWidget, widget: QWidget) -> None: + print(f"[CustomCloseWidget] Closing {widget.objectName()}") + area = widget.parent() + while area is not None and not isinstance(area, DockAreaWidget): + area = area.parent() + if isinstance(area, DockAreaWidget): + area.close_dock(dock, widget) + + class LambdaCloseWidget(QWidget): + """Example widget that relies on an explicit lambda passed to BasicDockArea.new.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("LambdaCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom lambda close handler – tabbed with Column 2 / Row 1.\n" + "Closing prints which dock triggered the callback.", + self, + ) + ) + + app = QApplication(sys.argv) + apply_theme("dark") + window = QMainWindow() + area = DockAreaWidget(root_widget=True, title="Basic Dock Area Demo") + window.setCentralWidget(area) + window.resize(1400, 800) + window.show() + + def make_panel(name: str, title: str, body: str = "") -> QWidget: + panel = QWidget() + panel.setObjectName(name) + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(title, panel)) + if body: + layout.addWidget(QLabel(body, panel)) + layout.addStretch(1) + return panel + + # Column 1: plain 'where' usage + col1_top = area.new( + make_panel("C1R1", "Column 1 / Row 1", "Added with where='left'."), + closable=True, + where="left", + return_dock=True, + show_settings_action=True, + ) + area.new( + make_panel("C1R2", "Column 1 / Row 2", "Stacked via relative_to + where='bottom'."), + closable=True, + where="bottom", + relative_to=col1_top, + ) + + # Column 2: relative placement and tabbing + col2_top = area.new( + make_panel( + "C2R1", "Column 2 / Row 1", "Placed to the right of Column 1 using relative_to." + ), + closable=True, + where="right", + relative_to=col1_top, + return_dock=True, + ) + area.new( + make_panel("C2R2", "Column 2 / Row 2", "Added beneath Column 2 / Row 1 via relative_to."), + closable=True, + where="bottom", + relative_to=col2_top, + ) + area.new( + make_panel("C2Tabbed", "Column 2 / Tabbed", "Tabbed with Column 2 / Row 1 using tab_with."), + closable=True, + tab_with=col2_top, + ) + + # Column 3: mix of where, relative_to, and custom close handler + col3_top = area.new( + make_panel("C3R1", "Column 3 / Row 1", "Placed to the right of Column 2 via relative_to."), + closable=True, + where="right", + relative_to=col2_top, + return_dock=True, + ) + area.new( + make_panel( + "C3R2", "Column 3 / Row 2", "Plain where='bottom' relative to Column 3 / Row 1." + ), + closable=True, + where="bottom", + relative_to=col3_top, + ) + area.new( + make_panel( + "C3Lambda", + "Column 3 / Tabbed Lambda", + "Tabbed with Column 3 / Row 1. Custom close handler prints the dock name.", + ), + closable=True, + tab_with=col3_top, + on_close=lambda dock, widget: ( + print(f"[Lambda handler] Closing {widget.objectName()}"), + area.close_dock(dock, widget), + ), + show_settings_action=True, + ) + + area.set_layout_ratios( + horizontal=[1, 1.5, 1], splitter_overrides={0: [3, 2], 1: [4, 3], 2: [2, 1]} + ) + + print("\nSplitter structure (paths for splitter_overrides):") + area.print_layout_structure() + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py new file mode 100644 index 000000000..129b24f92 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -0,0 +1,1170 @@ +from __future__ import annotations + +import os +from typing import Literal, Mapping, Sequence + +import slugify +from bec_lib import bec_logger +from qtpy.QtCore import Signal +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QApplication, + QDialog, + QInputDialog, + QMessageBox, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +import bec_widgets.widgets.containers.qt_ads as QtAds +from bec_widgets import BECWidget, SafeProperty, SafeSlot +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, + WidgetAction, +) +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 ( + 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, + now_iso_utc, + open_default_settings, + open_user_settings, + profile_origin, + profile_origin_display, + read_manifest, + restore_user_from_default, + set_last_profile, + set_quick_select, + user_profile_candidates, + write_manifest, +) +from bec_widgets.widgets.containers.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 ( + WorkspaceConnection, + workspace_bundle, +) +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC +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.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 import LogPanel +from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + +logger = bec_logger.logger + +_PROFILE_NAMESPACE_UNSET = object() + +PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} + + +class BECDockArea(DockAreaWidget): + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "widget_map", + "widget_list", + "workspace_is_locked", + "attach_all", + "delete_all", + "delete", + "set_layout_ratios", + "describe_layout", + "mode", + "mode.setter", + "list_profiles", + "save_profile", + "load_profile", + "delete_profile", + ] + + # Define a signal for mode changes + mode_changed = Signal(str) + profile_changed = Signal(str) + + def __init__( + self, + parent=None, + mode: Literal["plot", "device", "utils", "user", "creator"] = "creator", + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + profile_namespace: str | None = None, + auto_profile_namespace: bool = True, + instance_id: str | None = None, + auto_save_upon_exit: bool = True, + enable_profile_management: bool = True, + restore_initial_profile: bool = True, + init_profile: str | None = None, + start_empty: bool = False, + **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._auto_save_upon_exit = auto_save_upon_exit + self._profile_management_enabled = enable_profile_management + self._restore_initial_profile = restore_initial_profile + self._init_profile = init_profile + self._start_empty = start_empty + super().__init__( + parent, + default_add_direction=default_add_direction, + title="Advanced Dock Area", + **kwargs, + ) + + # Initialize mode property first (before toolbar setup) + self._mode = mode + + # Toolbar + self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self.dark_mode_button.setVisible(enable_profile_management) + self._setup_toolbar() + self._hook_toolbar() + + # Popups + self.save_dialog = None + self.manage_dialog = None + + # Place toolbar above the dock manager provided by the base class + self._root_layout.insertWidget(0, self.toolbar) + + # Populate and hook the workspace combo + self._refresh_workspace_list() + self._current_profile_name = None + self._pending_autosave_skip: tuple[str, str] | None = None + self._exit_snapshot_written = False + + # State manager + self.state_manager = WidgetStateManager( + self, serialize_from_root=True, root_id="AdvancedDockArea" + ) + + # Developer mode state + self._editable = None + # Initialize default editable state based on current lock + self._set_editable(True) # default to editable; will sync toolbar toggle below + + if self._ensure_initial_profile(): + self._refresh_workspace_list() + + # Apply the requested mode after everything is set up + self.mode = mode + if self._restore_initial_profile: + self._fetch_initial_profile() + + def _ensure_initial_profile(self) -> bool: + """ + Ensure the "general" workspace profile always exists for the current namespace. + The "general" profile is mandatory and will be recreated if deleted. + If list_profile fails due to file permission or corrupted profiles, no action taken. + + Returns: + bool: True if a profile was created, False otherwise. + """ + namespace = self.profile_namespace + try: + existing_profiles = list_profiles(namespace) + except Exception as exc: # pragma: no cover - defensive guard + logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}") + return False + + # Always ensure "general" profile exists + name = "general" + if name in existing_profiles: + return False + + logger.info( + f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace." + ) + + self._write_profile_settings(name, namespace, save_preview=False) + set_quick_select(name, True, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) + return True + + 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 + + # First priority: use init_profile if explicitly provided + if self._init_profile: + init_profile = self._init_profile + else: + # Try to restore from last used profile + 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): + 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: + # Fall back to "general" profile which is guaranteed to exist + if self._profile_exists("general", namespace): + init_profile = "general" + if init_profile: + self._load_initial_profile(init_profile) + + def _load_initial_profile(self, name: str) -> None: + """Load the initial profile.""" + self.load_profile(name, start_empty=self._start_empty) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.blockSignals(True) + combo.setCurrentText(name) + combo.blockSignals(False) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + prefs = getattr(dock, "_dock_preferences", {}) or {} + if prefs.get("show_settings_action") is None: + prefs = dict(prefs) + prefs["show_settings_action"] = True + dock._dock_preferences = prefs + super()._customize_dock(dock, widget) + + @SafeSlot(popup_error=True) + def new( + self, + widget: QWidget | str, + *, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, + tab_with: CDockWidget | QWidget | str | None = None, + relative_to: CDockWidget | QWidget | str | None = None, + 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: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + 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 `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). + 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. + """ + if show_settings_action is None: + show_settings_action = True + return super().new( + widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + tab_with=tab_with, + relative_to=relative_to, + 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, + ) + + def _apply_dock_lock(self, locked: bool) -> None: + if locked: + self.dock_manager.lockDockWidgetFeaturesGlobally() + else: + self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures) + + ################################################################################ + # Toolbar Setup + ################################################################################ + + def _setup_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + + plot_actions = { + "waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"), + "scatter_waveform": ( + ScatterWaveform.ICON_NAME, + "Add Scatter Waveform", + "ScatterWaveform", + ), + "multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"), + "image": (Image.ICON_NAME, "Add Image", "Image"), + "motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"), + "heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"), + } + device_actions = { + "scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"), + "positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"), + "positioner_box_2D": ( + PositionerBox2D.ICON_NAME, + "Add Device 2D Box", + "PositionerBox2D", + ), + } + util_actions = { + "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), + "status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), + "progress_bar": ( + RingProgressBar.ICON_NAME, + "Add Circular ProgressBar", + "RingProgressBar", + ), + "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), + "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), + "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), + "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), + } + + # Create expandable menu actions (original behavior) + def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): + self.toolbar.components.add_safe( + key, + ExpandableMenuAction( + label=label, + actions={ + k: MaterialIconAction( + icon_name=v[0], tooltip=v[1], filled=True, parent=self + ) + for k, v in mapping.items() + }, + ), + ) + b = ToolbarBundle(key, self.toolbar.components) + 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) + + # Create flat toolbar bundles for each widget type + def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): + bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components) + + for action_id, (icon_name, tooltip, widget_type) in mapping.items(): + # Create individual action for each widget type + flat_action_id = f"flat_{action_id}" + self.toolbar.components.add_safe( + flat_action_id, + MaterialIconAction( + icon_name=icon_name, + tooltip=tooltip, + filled=True, + parent=self, + label_text=widget_type, + text_position="under", + ), + ) + bundle.add_action(flat_action_id) + + self.toolbar.add_bundle(bundle) + + _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) + self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) + spacer_bundle.add_action("spacer") + self.toolbar.add_bundle(spacer_bundle) + + self.toolbar.add_bundle( + workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled) + ) + self.toolbar.connect_bundle( + "workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self) + ) + + # Dock actions + self.toolbar.components.add_safe( + "attach_all", + MaterialIconAction( + icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self + ), + ) + self.toolbar.components.get_action("attach_all").action.setVisible( + self._profile_management_enabled + ) + self.toolbar.components.add_safe( + "screenshot", + MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self), + ) + self.toolbar.components.get_action("screenshot").action.setVisible( + self._profile_management_enabled + ) + dark_mode_action = WidgetAction( + widget=self.dark_mode_button, adjust_size=False, parent=self + ) + dark_mode_action.widget.setVisible(self._profile_management_enabled) + self.toolbar.components.add_safe("dark_mode", dark_mode_action) + + bda = ToolbarBundle("dock_actions", self.toolbar.components) + bda.add_action("attach_all") + bda.add_action("screenshot") + bda.add_action("dark_mode") + 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, + } + + def _hook_toolbar(self): + def _connect_menu(menu_key: str): + menu = self.toolbar.components.get_action(menu_key) + mapping = self._ACTION_MAPPINGS[menu_key] + + # first two items not needed for this part + for key, (_, _, widget_type) in mapping.items(): + act = menu.actions[key].action + if widget_type == "LogPanel": + act.setEnabled(False) # keep disabled per issue #644 + elif key == "terminal": + act.triggered.connect( + lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) + ) + elif key == "bec_shell": + act.triggered.connect( + lambda _, t=widget_type: self.new( + widget=t, closable=True, show_settings_action=False + ) + ) + else: + act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_menu("menu_plots") + _connect_menu("menu_devices") + _connect_menu("menu_utils") + + def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]): + for action_id, (_, _, widget_type) in mapping.items(): + flat_action_id = f"flat_{action_id}" + flat_action = self.toolbar.components.get_action(flat_action_id).action + if widget_type == "LogPanel": + flat_action.setEnabled(False) # keep disabled per issue #644 + else: + flat_action.triggered.connect(lambda _, t=widget_type: self.new(t)) + + _connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"]) + + self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) + 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._editable = editable + + if self._profile_management_enabled: + self.toolbar.components.get_action("attach_all").action.setVisible(editable) + + def _on_developer_mode_toggled(self, checked: bool) -> None: + """Handle developer mode checkbox toggle.""" + self._set_editable(checked) + + ################################################################################ + # Workspace Management + ################################################################################ + @SafeProperty(bool) + def workspace_is_locked(self) -> bool: + """ + Get or set the lock state of the workspace. + + Returns: + bool: True if the workspace is locked, False otherwise. + """ + return self._locked + + @workspace_is_locked.setter + def workspace_is_locked(self, value: bool): + """ + Set the lock state of the workspace. Docks remain resizable, but are not movable or closable. + + Args: + value (bool): True to lock the workspace, False to unlock it. + """ + self._locked = value + self._apply_dock_lock(value) + if self._profile_management_enabled: + self.toolbar.components.get_action("save_workspace").action.setVisible(not value) + for dock in self.dock_list(): + dock.setting_action.setVisible(not value) + + def _last_profile_instance_id(self) -> str | None: + """ + Identifier used to scope the last-profile entry for this dock area. + + When unset, profiles are scoped only by namespace. + """ + return self._instance_id + + def _resolve_profile_namespace(self) -> str | None: + if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: + return self._profile_namespace_resolved # type: ignore[return-value] + + candidate = self._profile_namespace_hint + if self._profile_namespace_auto: + if not candidate: + obj_name = self.objectName() + candidate = obj_name if obj_name else None + if not candidate: + title = self.windowTitle() + candidate = title if title and title.strip() else None + if not candidate: + mode_name = getattr(self, "_mode", None) or "creator" + candidate = f"{mode_name}_workspace" + if not candidate: + candidate = self.__class__.__name__ + + resolved = slugify.slugify(candidate, separator="_") if candidate else None + if not resolved: + resolved = "general" + self._profile_namespace_resolved = resolved # type: ignore[assignment] + return resolved + + @property + def profile_namespace(self) -> str | None: + """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) + ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) + + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: + """ + Write the current workspace snapshot to the provided settings object. + + Args: + settings(QSettings): The settings object to write to. + save_preview(bool): Whether to save a screenshot preview. + """ + self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) + self.state_manager.save_state(settings=settings) + write_manifest(settings, self.dock_list()) + if save_preview: + ba = self.screenshot_bytes() + if ba and len(ba) > 0: + settings.setValue(SETTINGS_KEYS["screenshot"], ba) + settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc()) + + 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._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() + 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, + ): + """ + Save the current workspace profile. + + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + 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. + """ + + 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 + + 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" + + origin_before_save = profile_origin(name, namespace=namespace) + overwrite_default = overwrite_existing and origin_before_save == "settings" + + # Display saving placeholder in toolbar + 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 + 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 + ) + + set_quick_select(name, quickselect, namespace=namespace) + + self._refresh_workspace_list() + if current_profile and current_profile != name and not 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) + + @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(str) + @SafeSlot(str, bool) + @rpc_timeout(None) + def load_profile(self, name: str | None = None, start_empty: bool = False): + """ + 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. + start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. + """ + 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:" + ) + if not ok or not name: + return + + namespace = self.profile_namespace + prev_name = getattr(self, "_current_profile_name", None) + skip_pair = getattr(self, "_pending_autosave_skip", None) + if prev_name and prev_name != name: + if skip_pair and skip_pair == (prev_name, name): + self._pending_autosave_skip = None + else: + us_prev = open_user_settings(prev_name, namespace=namespace) + self._write_snapshot_to_settings(us_prev, save_preview=True) + + settings = None + if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)): + settings = open_user_settings(name, namespace=namespace) + 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) + return + + # Clear existing docks and remove all widgets + self.delete_all() + + if start_empty: + self._finalize_profile_change(name, namespace) + return + + # Rebuild widgets and restore states + for item in read_manifest(settings): + obj_name = item["object_name"] + widget_class = item["widget_class"] + if obj_name not in self.widget_map(): + w = widget_handler.create_widget(widget_type=widget_class, parent=self) + w.setObjectName(obj_name) + floating_state = None + if item.get("floating"): + floating_state = { + "relative": item.get("floating_relative"), + "absolute": item.get("floating_absolute"), + "screen_name": item.get("floating_screen"), + } + self._make_dock( + w, + closable=item["closable"], + floatable=item["floatable"], + movable=item["movable"], + start_floating=item.get("floating", False), + floating_state=floating_state, + area=QtAds.DockWidgetArea.RightDockWidgetArea, + ) + + self.load_from_settings(settings, keys=PROFILE_STATE_KEYS) + self.state_manager.load_state(settings=settings) + self._set_editable(self._editable) + + self._finalize_profile_change(name, namespace) + + @SafeSlot() + @SafeSlot(str) + def restore_user_profile_from_default(self, name: str | None = None): + """ + Overwrite the user copy of *name* with the default baseline. + If *name* is None, target the currently active profile. + + Args: + name (str | None): The name of the profile to restore. If None, uses the current profile. + """ + target = name or getattr(self, "_current_profile_name", None) + if not target: + return + namespace = self.profile_namespace + + current_pixmap = None + if self.isVisible(): + current_pixmap = QPixmap() + ba = bytes(self.screenshot_bytes()) + current_pixmap.loadFromData(ba) + if current_pixmap is None or current_pixmap.isNull(): + current_pixmap = load_user_profile_screenshot(target, namespace=namespace) + default_pixmap = load_default_profile_screenshot(target, namespace=namespace) + + if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap): + return + + restore_user_from_default(target, namespace=namespace) + self.delete_all() + self.load_profile(target) + + @SafeSlot() + def delete_profile(self, name: str | None = None, show_dialog: bool = False) -> bool: + """ + 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). + + """ + # Resolve profile name + if name is None: + 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.") + + 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 + self._refresh_workspace_list() + return True + + def _refresh_workspace_list(self): + """ + Populate the workspace combo box with all saved profile names (without .ini). + """ + combo = self.toolbar.components.get_action("workspace_combo").widget + active_profile = getattr(self, "_current_profile_name", None) + 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"): + combo.refresh_profiles(active_profile) + else: + # Fallback for regular QComboBox + combo.blockSignals(True) + combo.clear() + quick_profiles = list_quick_profiles(namespace=namespace) + items = list(quick_profiles) + if active_profile and active_profile not in items: + items.insert(0, active_profile) + combo.addItems(items) + if active_profile: + idx = combo.findText(active_profile) + if idx >= 0: + combo.setCurrentIndex(idx) + if active_profile and active_profile not in quick_profiles: + combo.setToolTip("Active profile is not in quick select") + else: + combo.setToolTip("") + combo.blockSignals(False) + + ################################################################################ + # Dialog Popups + ################################################################################ + + @SafeSlot() + def show_workspace_manager(self): + """ + Show the workspace manager dialog. + """ + manage_action = self.toolbar.components.get_action("manage_workspaces").action + if self.manage_dialog is None or not self.manage_dialog.isVisible(): + self.manage_widget = WorkSpaceManager( + self, target_widget=self, default_profile=self._current_profile_name + ) + self.manage_dialog = QDialog(modal=False) + + self.manage_dialog.setWindowTitle("Workspace Manager") + self.manage_dialog.setMinimumSize(1200, 500) + self.manage_dialog.layout = QVBoxLayout(self.manage_dialog) + self.manage_dialog.layout.addWidget(self.manage_widget) + self.manage_dialog.finished.connect(self._manage_dialog_closed) + self.manage_dialog.show() + self.manage_dialog.resize(300, 300) + manage_action.setChecked(True) + else: + # If already open, bring it to the front + self.manage_dialog.raise_() + self.manage_dialog.activateWindow() + manage_action.setChecked(True) # keep it toggle + + def _manage_dialog_closed(self): + self.manage_widget.close() + self.manage_widget.deleteLater() + if self.manage_dialog is not None: + self.manage_dialog.deleteLater() + self.manage_dialog = None + self.toolbar.components.get_action("manage_workspaces").action.setChecked(False) + + ################################################################################ + # Mode Switching + ################################################################################ + + @SafeProperty(str) + def mode(self) -> str: + return self._mode + + @mode.setter + def mode(self, new_mode: str): + allowed_modes = ["plot", "device", "utils", "user", "creator"] + if new_mode not in allowed_modes: + raise ValueError(f"Invalid mode: {new_mode}") + self._mode = new_mode + self.mode_changed.emit(new_mode) + self._apply_toolbar_layout() + + def _apply_toolbar_layout(self) -> None: + mode_key = getattr(self, "_mode", "creator") + if mode_key == "user": + bundles = ["spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "creator": + bundles = [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + elif mode_key == "plot": + bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "device": + bundles = ["flat_devices", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "utils": + bundles = ["flat_utils", "spacer_bundle", "workspace", "dock_actions"] + else: + bundles = ["spacer_bundle", "workspace", "dock_actions"] + + if not self._profile_management_enabled: + flat_only = [b for b in bundles if b.startswith("flat_")] + if not flat_only: + flat_only = ["flat_plots", "flat_devices", "flat_utils"] + bundles = flat_only + + self.toolbar.show_bundles(bundles) + + def prepare_for_shutdown(self) -> None: + """ + Persist the current workspace snapshot while the UI is still fully visible. + Called by the main window before initiating widget teardown to avoid capturing + close-triggered visibility changes. + """ + if ( + not self._auto_save_upon_exit + or getattr(self, "_exit_snapshot_written", False) + or getattr(self, "_destroyed", False) + ): + logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)") + return + + name = self._active_profile_name_or_default() + + namespace = self.profile_namespace + settings = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(settings) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) + self._exit_snapshot_written = True + + def cleanup(self): + """ + Cleanup the dock area. + """ + self.prepare_for_shutdown() + if self.manage_dialog is not None: + self.manage_dialog.reject() + self.manage_dialog = None + self.delete_all() + self.dark_mode_button.close() + self.dark_mode_button.deleteLater() + self.toolbar.cleanup() + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QApplication(sys.argv) + apply_theme("dark") + dispatcher = BECDispatcher(gui_id="ads") + window = BECMainWindowNoRPC() + + ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True) + + window.setCentralWidget(ads) + window.show() + window.resize(800, 1000) + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py new file mode 100644 index 000000000..267a7bdc2 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -0,0 +1,1050 @@ +""" +Utilities for managing AdvancedDockArea profiles stored in INI files. + +Policy: +- All created/modified profiles are stored under the BEC settings root: /profiles/{default,user} +- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to. +- Lookup order when reading: user → settings default → app or plugin bundled default. +""" + +from __future__ import annotations + +import os +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.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 + +MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + +ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] + + +def module_profiles_dir() -> str: + """ + 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", "advanced_dock_area", "profiles") + + +@lru_cache(maxsize=1) +def _plugin_repo_root() -> Path | None: + """ + Resolve the plugin repository root path if running inside a plugin context. + + Returns: + Path | None: Root path of the active plugin repository, or ``None`` when + no plugin context is detected. + """ + try: + return Path(plugin_repo_path()) + except ValueError: + return None + + +@lru_cache(maxsize=1) +def _plugin_display_name() -> str | None: + """ + Determine a user-friendly plugin name for provenance labels. + + Returns: + str | None: Human-readable name inferred from the plugin repo or package, + or ``None`` if it cannot be determined. + """ + repo_root = _plugin_repo_root() + if not repo_root: + return None + repo_name = repo_root.name + if repo_name: + return repo_name + try: + pkg = plugin_package_name() + except ValueError: + return None + return pkg.split(".")[0] if pkg else None + + +@lru_cache(maxsize=1) +def plugin_profiles_dir() -> str | None: + """ + Locate the read-only profiles directory shipped with a beamline plugin. + + Returns: + str | None: Directory containing bundled plugin profiles, or ``None`` if + no plugin profiles are available. + """ + repo_root = _plugin_repo_root() + if not repo_root: + return None + + candidates = [repo_root.joinpath("bec_widgets", "profiles")] + try: + package_root = repo_root.joinpath(*plugin_package_name().split(".")) + candidates.append(package_root.joinpath("bec_widgets", "profiles")) + except ValueError as e: + logger.error(f"Could not determine plugin package name: {e}") + + for candidate in candidates: + if candidate.is_dir(): + return str(candidate) + return None + + +def _settings_profiles_root() -> str: + """ + Resolve the writable profiles root provided by the BEC client. + + Returns: + str: Absolute path to the profiles root. The directory is created if missing. + """ + client = BECClient() + 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.environ.get("BECWIDGETS_PROFILE_DIR", default_path) + os.makedirs(root, exist_ok=True) + return root + + +def _profiles_dir(segment: str, namespace: str | None) -> str: + """ + Build (and ensure) the directory that holds profiles for a namespace segment. + + Args: + segment (str): Either ``"user"`` or ``"default"``. + namespace (str | None): Optional namespace label to scope profiles. + + Returns: + 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.join(base, ns) if ns else base + os.makedirs(path, exist_ok=True) + return path + + +def _user_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate user-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate user profile paths (.ini files). + """ + ns = slugify.slugify(namespace, separator="_") if namespace else None + primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def _default_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate default-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate default profile paths (.ini files). + """ + ns = slugify.slugify(namespace, separator="_") if namespace else None + primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def default_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores default profiles for the namespace. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the default profile directory. + """ + return _profiles_dir("default", namespace) + + +def user_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores user profiles for the namespace. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile directory. + """ + return _profiles_dir("user", namespace) + + +def default_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical default profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the default profile file (.ini). + """ + return _default_path_candidates(name, namespace)[0] + + +def user_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical user profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile file (.ini). + """ + return _user_path_candidates(name, namespace)[0] + + +def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all user profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate user profile paths. + """ + return list(dict.fromkeys(_user_path_candidates(name, namespace))) + + +def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all default profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate default profile paths. + """ + return list(dict.fromkeys(_default_path_candidates(name, namespace))) + + +def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing user profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing user profile candidate, or ``None`` + when no files are present. + """ + for path in user_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None + + +def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing default profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing default profile candidate, or ``None`` + when no files are present. + """ + for path in default_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None + + +def module_profile_path(name: str) -> str: + """ + Build the absolute path to a bundled module profile. + + Args: + name (str): Profile name without extension. + + Returns: + str: Absolute path to the module's read-only profile file. + """ + return os.path.join(module_profiles_dir(), f"{name}.ini") + + +def plugin_profile_path(name: str) -> str | None: + """ + Build the absolute path to a bundled plugin profile if available. + + Args: + name (str): Profile name without extension. + + Returns: + str | None: Absolute plugin profile path, or ``None`` when plugins do not + provide profiles. + """ + directory = plugin_profiles_dir() + if not directory: + return None + return os.path.join(directory, f"{name}.ini") + + +def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin: + """ + Determine where a profile originates from. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + + Returns: + ProfileOrigin: ``"module"`` for bundled BEC profiles, ``"plugin"`` for beamline + plugin bundles, ``"settings"`` for writable copies, and ``"unknown"`` when + no backing files are found. + """ + if os.path.exists(module_profile_path(name)): + return "module" + plugin_path = plugin_profile_path(name) + if plugin_path and os.path.exists(plugin_path): + return "plugin" + for path in user_profile_candidates(name, namespace) + default_profile_candidates( + name, namespace + ): + if os.path.exists(path): + return "settings" + return "unknown" + + +def is_profile_read_only(name: str, namespace: str | None = None) -> bool: + """ + Check whether a profile is read-only because it originates from bundles. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + + Returns: + bool: ``True`` if the profile originates from module or plugin bundles. + """ + return profile_origin(name, namespace) in {"module", "plugin"} + + +def profile_origin_display(name: str, namespace: str | None = None) -> str | None: + """ + Build a user-facing label describing a profile's origin. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + + Returns: + str | None: Localized display label such as ``"BEC Widgets"`` or ``"User"``, + or ``None`` when origin cannot be determined. + """ + origin = profile_origin(name, namespace) + if origin == "module": + return "BEC Widgets" + if origin == "plugin": + return _plugin_display_name() + if origin == "settings": + return "User" + return None + + +def delete_profile_files(name: str, namespace: str | None = None) -> bool: + """ + Delete the profile files from the writable settings directories. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label scoped to the profile. Defaults + to ``None``. + + Returns: + bool: ``True`` if at least one file was removed. + """ + read_only = is_profile_read_only(name, namespace) + + removed = False + # Always allow removing user copies; keep default copies for read-only origins. + for path in set(user_profile_candidates(name, namespace)): + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if not read_only: + for path in set(default_profile_candidates(name, namespace)): + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if removed and get_last_profile(namespace) == name: + set_last_profile(None, namespace) + + return removed + + +SETTINGS_KEYS = { + "geom": "mainWindow/Geometry", + "state": "mainWindow/State", + "ads_state": "mainWindow/DockingState", + "manifest": "manifest/widgets", + "created_at": "profile/created_at", + "is_quick_select": "profile/quick_select", + "screenshot": "profile/screenshot", + "screenshot_at": "profile/screenshot_at", + "last_profile": "app/last_profile", +} + + +def list_profiles(namespace: str | None = None) -> list[str]: + """ + Enumerate all known profile names, syncing bundled defaults when missing locally. + + Args: + namespace (str | None, optional): Namespace label scoped to the profile set. + Defaults to ``None``. + + Returns: + list[str]: Sorted unique profile names. + """ + ns = slugify.slugify(namespace, separator="_") if namespace else None + + def _collect_from(directory: str) -> set[str]: + if not os.path.isdir(directory): + return set() + return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")} + + settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)} + if ns: + settings_dirs.add(default_profiles_dir(None)) + settings_dirs.add(user_profiles_dir(None)) + + settings_names: set[str] = set() + for directory in settings_dirs: + settings_names |= _collect_from(directory) + + # Also consider read-only defaults from core module and beamline plugin repositories + read_only_sources: dict[str, tuple[str, str]] = {} + sources: list[tuple[str, str | None]] = [ + ("module", module_profiles_dir()), + ("plugin", plugin_profiles_dir()), + ] + for origin, directory in sources: + if not directory or not os.path.isdir(directory): + continue + for filename in os.listdir(directory): + if not filename.endswith(".ini"): + continue + name, _ = os.path.splitext(filename) + read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) + + for name, (_origin, src) in sorted(read_only_sources.items()): + # Ensure a copy in the namespace-specific settings default directory + dst_default = default_profile_path(name, namespace) + if not os.path.exists(dst_default): + os.makedirs(os.path.dirname(dst_default), exist_ok=True) + shutil.copyfile(src, dst_default) + # Ensure a user copy exists to allow edits in the writable settings area + dst_user = user_profile_path(name, namespace) + if not os.path.exists(dst_user): + os.makedirs(os.path.dirname(dst_user), exist_ok=True) + shutil.copyfile(src, dst_user) + s = open_user_settings(name, namespace) + if s.value(SETTINGS_KEYS["created_at"], "") == "": + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + + settings_names |= set(read_only_sources.keys()) + + # Return union of all discovered names + return sorted(settings_names) + + +def open_default_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the default profile settings file. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QSettings: Settings instance targeting the default profile file. + """ + return QSettings(default_profile_path(name, namespace), QSettings.IniFormat) + + +def open_user_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the user profile settings file. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QSettings: Settings instance targeting the user profile file. + """ + return QSettings(user_profile_path(name, namespace), QSettings.IniFormat) + + +def _app_settings() -> QSettings: + """ + Access the application-wide metadata settings file for dock profiles. + + Returns: + QSettings: Handle to the ``_meta.ini`` metadata store under the profiles root. + """ + return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) + + +def _last_profile_key(namespace: str | None, instance: str | None = None) -> str: + """ + Build the QSettings key used to store the last profile per namespace and + optional instance id. + + Args: + namespace (str | None): Namespace label. + + Returns: + str: Scoped key string. + """ + ns = slugify.slugify(namespace, separator="_") if namespace else None + key = SETTINGS_KEYS["last_profile"] + if ns: + key = f"{key}/{ns}" + inst = slugify.slugify(instance, separator="_") if instance else "" + if inst: + key = f"{key}@{inst}" + return key + + +def get_last_profile( + namespace: str | None = None, + instance: str | None = None, + *, + allow_namespace_fallback: bool = True, +) -> str | None: + """ + Retrieve the last-used profile name persisted in app settings. + + When *instance* is provided, the lookup is scoped to that particular dock + area instance. If the instance-specific entry is missing and + ``allow_namespace_fallback`` is True, the namespace-wide entry is + consulted next. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. + allow_namespace_fallback (bool): Whether to fall back to the namespace + entry when an instance-specific value is not found. Defaults to ``True``. + + Returns: + str | None: Profile name or ``None`` if none has been stored. + """ + s = _app_settings() + inst = instance or None + if inst: + name = s.value(_last_profile_key(namespace, inst), "", type=str) + if name: + return name + if not allow_namespace_fallback: + return None + name = s.value(_last_profile_key(namespace, None), "", type=str) + return name or None + + +def set_last_profile( + name: str | None, namespace: str | None = None, instance: str | None = None +) -> None: + """ + Persist the last-used profile name (or clear the value when ``None``). + + When *instance* is provided, the value is stored under a key specific to + that dock area instance; otherwise it is stored under the namespace-wide key. + + Args: + name (str | None): Profile name to store. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. + """ + s = _app_settings() + key = _last_profile_key(namespace, instance) + if name: + s.setValue(key, name) + else: + s.remove(key) + + +def now_iso_utc() -> str: + """ + Return the current UTC timestamp formatted in ISO 8601. + + Returns: + str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``). + """ + return QDateTime.currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ssZ") + + +def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: + """ + Write the manifest of dock widgets to settings. + + Args: + settings(QSettings): Settings object to write to. + docks(list[CDockWidget]): List of dock widgets to serialize. + """ + + def _floating_snapshot(dock: CDockWidget) -> dict | None: + if not hasattr(dock, "isFloating") or not dock.isFloating(): + return None + container = dock.floatingDockContainer() if hasattr(dock, "floatingDockContainer") else None + if container is None: + return None + geom = container.frameGeometry() + if geom.isNull(): + return None + absolute = {"x": geom.x(), "y": geom.y(), "w": geom.width(), "h": geom.height()} + screen = container.screen() if hasattr(container, "screen") else None + if screen is None: + screen = QApplication.screenAt(geom.center()) if QApplication.instance() else None + screen_name = "" + relative = None + if screen is not None: + if hasattr(screen, "name"): + try: + screen_name = screen.name() + except Exception: + screen_name = "" + avail = screen.availableGeometry() + width = max(1, avail.width()) + height = max(1, avail.height()) + relative = { + "x": (geom.left() - avail.left()) / float(width), + "y": (geom.top() - avail.top()) / float(height), + "w": geom.width() / float(width), + "h": geom.height() / float(height), + } + return {"screen_name": screen_name, "relative": relative, "absolute": absolute} + + ordered_docks = [dock for dock in docks if dock.isFloating()] + [ + dock for dock in docks if not dock.isFloating() + ] + settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(ordered_docks)) + for i, dock in enumerate(ordered_docks): + settings.setArrayIndex(i) + w = dock.widget() + settings.setValue("object_name", w.objectName()) + settings.setValue("widget_class", w.__class__.__name__) + settings.setValue("closable", getattr(dock, "_default_closable", True)) + settings.setValue("floatable", getattr(dock, "_default_floatable", True)) + settings.setValue("movable", getattr(dock, "_default_movable", True)) + is_floating = bool(dock.isFloating()) + settings.setValue("floating", is_floating) + if is_floating: + snapshot = _floating_snapshot(dock) + if snapshot: + relative = snapshot.get("relative") or {} + absolute = snapshot.get("absolute") or {} + settings.setValue("floating_screen", snapshot.get("screen_name", "")) + settings.setValue("floating_rel_x", relative.get("x", 0.0)) + settings.setValue("floating_rel_y", relative.get("y", 0.0)) + settings.setValue("floating_rel_w", relative.get("w", 0.0)) + settings.setValue("floating_rel_h", relative.get("h", 0.0)) + settings.setValue("floating_abs_x", absolute.get("x", 0)) + settings.setValue("floating_abs_y", absolute.get("y", 0)) + settings.setValue("floating_abs_w", absolute.get("w", 0)) + settings.setValue("floating_abs_h", absolute.get("h", 0)) + else: + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) + settings.endArray() + + +def read_manifest(settings: QSettings) -> list[dict]: + """ + Read the manifest of dock widgets from settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + list[dict]: List of dock widget metadata dictionaries. + """ + items: list[dict] = [] + count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) + for i in range(count): + settings.setArrayIndex(i) + floating = settings.value("floating", False, type=bool) + rel = { + "x": float(settings.value("floating_rel_x", 0.0)), + "y": float(settings.value("floating_rel_y", 0.0)), + "w": float(settings.value("floating_rel_w", 0.0)), + "h": float(settings.value("floating_rel_h", 0.0)), + } + abs_geom = { + "x": int(settings.value("floating_abs_x", 0)), + "y": int(settings.value("floating_abs_y", 0)), + "w": int(settings.value("floating_abs_w", 0)), + "h": int(settings.value("floating_abs_h", 0)), + } + if not floating: + rel = None + abs_geom = None + items.append( + { + "object_name": settings.value("object_name"), + "widget_class": settings.value("widget_class"), + "closable": settings.value("closable", type=bool), + "floatable": settings.value("floatable", type=bool), + "movable": settings.value("movable", type=bool), + "floating": floating, + "floating_screen": settings.value("floating_screen", ""), + "floating_relative": rel, + "floating_absolute": abs_geom, + } + ) + settings.endArray() + return items + + +def restore_user_from_default(name: str, namespace: str | None = None) -> None: + """ + Copy the default profile to the user profile, preserving quick-select flag. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + src = None + for candidate in default_profile_candidates(name, namespace): + if os.path.exists(candidate): + src = candidate + break + if not src: + return + dst = user_profile_path(name, namespace) + preserve_quick_select = is_quick_select(name, namespace) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(src, dst) + s = open_user_settings(name, namespace) + if not s.value(SETTINGS_KEYS["created_at"], ""): + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if preserve_quick_select: + s.setValue(SETTINGS_KEYS["is_quick_select"], True) + + +def is_quick_select(name: str, namespace: str | None = None) -> bool: + """ + Return True if profile is marked to appear in quick-select combo. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + bool: True if quick-select is enabled for the profile. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) + if s is None: + return False + return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) + + +def set_quick_select(name: str, enabled: bool, namespace: str | None = None) -> None: + """ + Set or clear the quick-select flag for a profile. + + Args: + name(str): Profile name without extension. + enabled(bool): True to enable quick-select, False to disable. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + s = open_user_settings(name, namespace) + s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) + + +def list_quick_profiles(namespace: str | None = None) -> list[str]: + """ + List only profiles that have quick-select enabled (user wins over default). + + Args: + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: Sorted list of profile names with quick-select enabled. + """ + names = list_profiles(namespace) + return [n for n in names if is_quick_select(n, namespace)] + + +def _file_modified_iso(path: str) -> str: + """ + Get the file modification time as an ISO 8601 UTC string. + + Args: + path(str): Path to the file. + + Returns: + str: ISO 8601 UTC timestamp of last modification, or current time if unavailable. + """ + try: + mtime = os.path.getmtime(path) + return QDateTime.fromSecsSinceEpoch(int(mtime), QTimeZone.utc()).toString( + "yyyy-MM-ddTHH:mm:ssZ" + ) + except Exception: + return now_iso_utc() + + +def _manifest_count(settings: QSettings) -> int: + """ + Get the number of widgets recorded in the manifest. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + int: Number of widgets in the manifest. + """ + n = settings.beginReadArray(SETTINGS_KEYS["manifest"]) + settings.endArray() + return int(n or 0) + + +def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: + """ + Load the screenshot pixmap stored in the given settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + data = settings.value(SETTINGS_KEYS["screenshot"], None) + if not data: + return None + + buf = None + if isinstance(data, QByteArray): + buf = data + elif isinstance(data, (bytes, bytearray, memoryview)): + buf = bytes(data) + elif isinstance(data, str): + try: + buf = QByteArray(data.encode("latin-1")) + except Exception: + buf = None + + if buf is None: + return None + + pm = QPixmap() + ok = pm.loadFromData(buf) + return pm if ok and not pm.isNull() else None + + +class ProfileInfo(BaseModel): + """Pydantic model capturing profile metadata surfaced in the UI.""" + + name: str + author: str = "BEC Widgets" + notes: str = "" + created: str = Field(default_factory=now_iso_utc) + modified: str = Field(default_factory=now_iso_utc) + is_quick_select: bool = False + widget_count: int = 0 + size_kb: int = 0 + user_path: str = "" + default_path: str = "" + origin: ProfileOrigin = "unknown" + is_read_only: bool = False + + +def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo: + """ + Assemble metadata and statistics for a profile. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + ProfileInfo: Structured profile metadata, preferring the user copy when present. + """ + user_paths = user_profile_candidates(name, namespace) + default_paths = default_profile_candidates(name, namespace) + u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0]) + d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0]) + origin = profile_origin(name, namespace) + read_only = origin in {"module", "plugin"} + prefer_user = os.path.exists(u_path) + if prefer_user: + s = QSettings(u_path, QSettings.IniFormat) + elif os.path.exists(d_path): + s = QSettings(d_path, QSettings.IniFormat) + else: + s = None + if s is None: + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = "" + return ProfileInfo( + name=name, + author=author, + notes="", + created=now_iso_utc(), + modified=now_iso_utc(), + is_quick_select=False, + widget_count=0, + size_kb=0, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc() + src_path = u_path if prefer_user else d_path + modified = _file_modified_iso(src_path) + count = _manifest_count(s) + try: + size_kb = int(os.path.getsize(src_path) / 1024) + except Exception: + size_kb = 0 + settings_author = s.value("profile/author", "", type=str) or None + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = settings_author or "user" + + return ProfileInfo( + name=name, + author=author, + notes=s.value("profile/notes", "", type=str) or "", + created=created, + modified=modified, + is_quick_select=is_quick_select(name, namespace), + widget_count=count, + size_kb=size_kb, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + +def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the stored screenshot pixmap for a profile from settings (user preferred). + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) + if s is None: + return None + return _load_screenshot_from_settings(s) + + +def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the default profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_default_settings(name, namespace) + if s is None: + return None + return _load_screenshot_from_settings(s) + + +def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the user profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: + return None + return _load_screenshot_from_settings(s) diff --git a/bec_widgets/widgets/containers/dock_area/settings/__init__.py b/bec_widgets/widgets/containers/dock_area/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/dock_area/settings/dialogs.py new file mode 100644 index 000000000..19329c34b --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/settings/dialogs.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from typing import Callable, Literal + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QCheckBox, + QDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeSlot + + +class SaveProfileDialog(QDialog): + """Dialog for saving workspace profiles with quick select option.""" + + def __init__( + self, + parent: QWidget | None = None, + current_name: str = "", + current_profile_name: str = "", + *, + name_exists: Callable[[str], bool] | None = None, + profile_origin: ( + Callable[[str], Literal["module", "plugin", "settings", "unknown"]] | None + ) = None, + origin_label: Callable[[str], str | None] | None = None, + quick_select_checked: bool = False, + ): + super().__init__(parent) + self.setWindowTitle("Save Workspace Profile") + self.setModal(True) + self.resize(400, 160) + + self._name_exists = name_exists or (lambda _: False) + self._profile_origin = profile_origin or (lambda _: "unknown") + self._origin_label = origin_label or (lambda _: None) + self._current_profile_name = current_profile_name.strip() + self._previous_name_before_overwrite = current_name + self._block_name_signals = False + self._block_checkbox_signals = False + self.overwrite_existing = False + + layout = QVBoxLayout(self) + + # Name input + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Profile Name:")) + self.name_edit = QLineEdit(current_name) + self.name_edit.setPlaceholderText("Enter profile name...") + name_row.addWidget(self.name_edit) + layout.addLayout(name_row) + + # Overwrite checkbox + self.overwrite_checkbox = QCheckBox("Overwrite current profile") + self.overwrite_checkbox.setEnabled(bool(self._current_profile_name)) + self.overwrite_checkbox.toggled.connect(self._on_overwrite_toggled) + layout.addWidget(self.overwrite_checkbox) + + # Quick-select checkbox + self.quick_select_checkbox = QCheckBox("Include in quick selection.") + self.quick_select_checkbox.setChecked(quick_select_checked) + layout.addWidget(self.quick_select_checkbox) + + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch(1) + self.save_btn = QPushButton("Save") + self.save_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + self.save_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self.save_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Enable/disable save button based on name input + self.name_edit.textChanged.connect(self._on_name_changed) + self._update_save_button() + + @SafeSlot(bool) + def _on_overwrite_toggled(self, checked: bool): + if self._block_checkbox_signals: + return + if not self._current_profile_name: + return + + self._block_name_signals = True + if checked: + self._previous_name_before_overwrite = self.name_edit.text() + self.name_edit.setText(self._current_profile_name) + self.name_edit.selectAll() + else: + if self.name_edit.text().strip() == self._current_profile_name: + self.name_edit.setText(self._previous_name_before_overwrite or "") + self._block_name_signals = False + self._update_save_button() + + @SafeSlot(str) + def _on_name_changed(self, _: str): + if self._block_name_signals: + return + text = self.name_edit.text().strip() + if self.overwrite_checkbox.isChecked() and text != self._current_profile_name: + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + self._update_save_button() + + def _update_save_button(self): + """Enable save button only when name is not empty.""" + self.save_btn.setEnabled(bool(self.name_edit.text().strip())) + + def get_profile_name(self) -> str: + """Return the entered profile name.""" + return self.name_edit.text().strip() + + def is_quick_select(self) -> bool: + """Return whether the profile should appear in quick select.""" + return self.quick_select_checkbox.isChecked() + + def _generate_unique_name(self, base: str) -> str: + candidate_base = base.strip() or "profile" + suffix = "_custom" + candidate = f"{candidate_base}{suffix}" + counter = 1 + while self._name_exists(candidate) or self._profile_origin(candidate) != "unknown": + candidate = f"{candidate_base}{suffix}_{counter}" + counter += 1 + return candidate + + def accept(self): + name = self.get_profile_name() + if not name: + return + + self.overwrite_existing = False + origin = self._profile_origin(name) + if origin in {"module", "plugin"}: + source_label = self._origin_label(name) + if origin == "module": + provider = source_label or "BEC Widgets" + else: + provider = ( + f"the {source_label} plugin repository" + if source_label + else "the plugin repository" + ) + QMessageBox.information( + self, + "Read-only profile", + ( + f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n" + "Please choose a different name." + ), + ) + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + if origin == "settings": + reply = QMessageBox.question( + self, + "Overwrite profile", + ( + f"A profile named '{name}' already exists.\n\n" + "Overwriting will update both the saved profile and its restore default.\n" + "Do you want to continue?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + self.overwrite_existing = True + + super().accept() + + +class PreviewPanel(QGroupBox): + """Resizable preview pane that scales its pixmap with aspect ratio preserved.""" + + def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None): + super().__init__(title, parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None + + layout = QVBoxLayout(self) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setMinimumSize(360, 240) + self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + layout.addWidget(self.image_label, 1) + + if self._original: + self._update_scaled_pixmap() + else: + self.image_label.setText("No preview available") + self.image_label.setStyleSheet( + self.image_label.styleSheet() + "color: rgba(255,255,255,0.6); font-style: italic;" + ) + + def setPixmap(self, pixmap: QPixmap | None): + """ + Set the pixmap to display in the preview panel. + + Args: + pixmap(QPixmap | None): The pixmap to display. If None or null, clears the preview. + + """ + self._original = pixmap if (pixmap and not pixmap.isNull()) else None + if self._original: + self.image_label.setText("") + self._update_scaled_pixmap() + else: + self.image_label.setPixmap(QPixmap()) + self.image_label.setText("No preview available") + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._original: + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self): + if not self._original: + return + size = self.image_label.size() + if size.width() <= 0 or size.height() <= 0: + return + scaled = self._original.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.image_label.setPixmap(scaled) + + +class RestoreProfileDialog(QDialog): + """ + Confirmation dialog that previews the current profile screenshot against the default baseline. + """ + + def __init__( + self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ): + super().__init__(parent) + self.setWindowTitle("Restore Profile to Default") + self.setModal(True) + self.resize(880, 480) + + layout = QVBoxLayout(self) + + info_label = QLabel( + "Restoring will discard your custom layout and replace it with the default profile." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + preview_row = QHBoxLayout() + layout.addLayout(preview_row) + + current_preview = PreviewPanel("Current", current_pixmap, self) + default_preview = PreviewPanel("Default", default_pixmap, self) + + # Equal expansion left/right + preview_row.addWidget(current_preview, 1) + + arrow_label = QLabel("\u2192") + arrow_label.setAlignment(Qt.AlignCenter) + arrow_label.setStyleSheet("font-size: 32px; padding: 0 16px;") + arrow_label.setMinimumWidth(40) + arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + preview_row.addWidget(arrow_label) + + preview_row.addWidget(default_preview, 1) + + # Enforce equal stretch for both previews + preview_row.setStretch(0, 1) + preview_row.setStretch(1, 0) + preview_row.setStretch(2, 1) + + warn_label = QLabel( + "This action cannot be undone. Do you want to restore the default layout now?" + ) + warn_label.setWordWrap(True) + layout.addWidget(warn_label) + + btn_row = QHBoxLayout() + btn_row.addStretch(1) + restore_btn = QPushButton("Restore") + restore_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + restore_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(restore_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Make the previews take most of the vertical space on resize + layout.setStretch(0, 0) # info label + layout.setStretch(1, 1) # preview row + layout.setStretch(2, 0) # warning label + layout.setStretch(3, 0) # buttons + + @staticmethod + def confirm( + parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ) -> bool: + dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap) + return dialog.exec() == QDialog.Accepted diff --git a/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py new file mode 100644 index 000000000..a4f3a3fc6 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +from functools import partial + +from bec_lib import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +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 ( + get_profile_info, + is_quick_select, + list_profiles, + load_profile_screenshot, + set_quick_select, +) + +logger = bec_logger.logger + + +class WorkSpaceManager(BECWidget, QWidget): + RPC = False + PLUGIN = False + COL_ACTIONS = 0 + COL_NAME = 1 + COL_AUTHOR = 2 + HEADERS = ["Actions", "Profile", "Author"] + + def __init__( + self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.target_widget = target_widget + self.profile_namespace = ( + getattr(target_widget, "profile_namespace", None) if target_widget else None + ) + self.accent_colors = get_accent_colors() + self._init_ui() + if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): + self.target_widget.profile_changed.connect(self.on_profile_changed) + if default_profile is not None: + self._select_by_name(default_profile) + self._show_profile_details(default_profile) + + def _init_ui(self): + self.root_layout = QHBoxLayout(self) + self.splitter = QSplitter(Qt.Horizontal, self) + self.root_layout.addWidget(self.splitter) + + # Init components + self._init_profile_table() + self._init_profile_details_tree() + self._init_screenshot_preview() + + # Build two-column layout + left_col = QVBoxLayout() + left_col.addWidget(self.profile_table, 1) + left_col.addWidget(self.profile_details_tree, 0) + + self.save_profile_button = QPushButton("Save current layout as new profile", self) + self.save_profile_button.clicked.connect(self.save_current_as_profile) + left_col.addWidget(self.save_profile_button) + self.save_profile_button.setEnabled(self.target_widget is not None) + + # Wrap left widgets into a panel that participates in splitter sizing + left_panel = QWidget(self) + left_panel.setLayout(left_col) + left_panel.setMinimumWidth(220) + + # Make the screenshot preview expand to fill remaining space + self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.right_box = QGroupBox("Profile Screenshot Preview", self) + right_col = QVBoxLayout(self.right_box) + right_col.addWidget(self.screenshot_label, 1) + + self.splitter.addWidget(left_panel) + self.splitter.addWidget(self.right_box) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([350, 650]) + + def _init_profile_table(self): + self.profile_table = QTableWidget(self) + self.profile_table.setColumnCount(len(self.HEADERS)) + self.profile_table.setHorizontalHeaderLabels(self.HEADERS) + self.profile_table.setAlternatingRowColors(True) + self.profile_table.verticalHeader().setVisible(False) + + # Enforce row selection, single-select, and disable edits + self.profile_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.profile_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.profile_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + + # Ensure the table expands to use vertical space in the left panel + self.profile_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + header = self.profile_table.horizontalHeader() + header.setStretchLastSection(False) + header.setDefaultAlignment(Qt.AlignCenter) + + class _CenterDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignCenter + + self.profile_table.setItemDelegate(_CenterDelegate(self.profile_table)) + + header.setSectionResizeMode(self.COL_ACTIONS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_AUTHOR, QHeaderView.ResizeToContents) + self.render_table() + self.profile_table.itemSelectionChanged.connect(self._on_table_selection_changed) + self.profile_table.cellClicked.connect(self._on_cell_clicked) + + def _init_profile_details_tree(self): + self.profile_details_tree = QTreeWidget(self) + self.profile_details_tree.setHeaderLabels(["Field", "Value"]) + # Keep details compact so the table can expand + self.profile_details_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + def _init_screenshot_preview(self): + self.screenshot_label = QLabel(self) + self.screenshot_label.setMinimumHeight(160) + self.screenshot_label.setAlignment(Qt.AlignCenter) + + def render_table(self): + self.profile_table.setRowCount(0) + for profile in list_profiles(namespace=self.profile_namespace): + self._add_profile_row(profile) + + def _add_profile_row(self, name: str): + row = self.profile_table.rowCount() + self.profile_table.insertRow(row) + + actions_items = QWidget(self) + actions_items.profile_name = name + actions_items_layout = QHBoxLayout(actions_items) + actions_items_layout.setContentsMargins(0, 0, 0, 0) + + info = get_profile_info(name, namespace=self.profile_namespace) + + # Flags + is_active = ( + self.target_widget is not None + and getattr(self.target_widget, "_current_profile_name", None) == name + ) + quick = info.is_quick_select + is_read_only = info.is_read_only + + # Play (green if active) + self._make_action_button( + actions_items, + "play_circle", + "Switch to this profile", + self.switch_profile, + filled=is_active, + color=(self.accent_colors.success if is_active else None), + ) + + # Quick-select (yellow if enabled) + self._make_action_button( + actions_items, + "star", + "Include in quick selection", + self.toggle_quick_select, + filled=quick, + color=(self.accent_colors.warning if quick else None), + ) + + # Delete (red, disabled when read-only) + delete_button = self._make_action_button( + actions_items, + "delete", + "Delete this profile", + self.delete_profile, + color=self.accent_colors.emergency, + ) + if is_read_only: + delete_button.setEnabled(False) + delete_button.setToolTip("Bundled profiles are read-only and cannot be deleted.") + + actions_items_layout.addStretch() + + self.profile_table.setCellWidget(row, self.COL_ACTIONS, actions_items) + self.profile_table.setItem(row, self.COL_NAME, QTableWidgetItem(name)) + self.profile_table.setItem(row, self.COL_AUTHOR, QTableWidgetItem(info.author)) + + def _make_action_button( + self, + parent: QWidget, + icon_name: str, + tooltip: str, + slot: callable, + *, + filled: bool = False, + color: str | None = None, + ): + button = QToolButton(parent=parent) + button.setIcon(material_icon(icon_name, filled=filled, color=color)) + button.setToolTip(tooltip) + button.clicked.connect(partial(slot, parent.profile_name)) + parent.layout().addWidget(button) + return button + + def _select_by_name(self, name: str) -> None: + for row in range(self.profile_table.rowCount()): + item = self.profile_table.item(row, self.COL_NAME) + if item and item.text() == name: + self.profile_table.selectRow(row) + break + + def _current_selected_profile(self) -> str | None: + rows = self.profile_table.selectionModel().selectedRows() + if not rows: + return None + row = rows[0].row() + item = self.profile_table.item(row, self.COL_NAME) + return item.text() if item else None + + def _show_profile_details(self, name: str) -> None: + info = get_profile_info(name, namespace=self.profile_namespace) + self.profile_details_tree.clear() + entries = [ + ("Name", info.name), + ("Author", info.author or ""), + ("Created", info.created or ""), + ("Modified", info.modified or ""), + ("Quick select", "Yes" if info.is_quick_select else "No"), + ("Widgets", str(info.widget_count)), + ("Size (KB)", str(info.size_kb)), + ("User path", info.user_path or ""), + ("Default path", info.default_path or ""), + ] + for k, v in entries: + self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v])) + self.profile_details_tree.expandAll() + + # Render screenshot preview from profile INI + pm = load_profile_screenshot(name, namespace=self.profile_namespace) + if pm is not None and not pm.isNull(): + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) + else: + self.screenshot_label.setPixmap(QPixmap()) + + @SafeSlot() + def _on_table_selection_changed(self): + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot(int, int) + def _on_cell_clicked(self, row: int, column: int): + item = self.profile_table.item(row, self.COL_NAME) + if item: + self._show_profile_details(item.text()) + + ################################################## + # Public Slots + ################################################## + @SafeSlot(str) + def on_profile_changed(self, name: str): + """Keep the manager in sync without forcing selection to the active profile.""" + selected = self._current_selected_profile() + self.render_table() + if selected: + self._select_by_name(selected) + self._show_profile_details(selected) + + @SafeSlot(str) + def switch_profile(self, profile_name: str): + self.target_widget.load_profile(profile_name) + try: + self.target_widget.toolbar.components.get_action( + "workspace_combo" + ).widget.setCurrentText(profile_name) + except Exception as e: + logger.warning(f"Warning: Could not update workspace combo box. {e}") + + self.render_table() + self._select_by_name(profile_name) + self._show_profile_details(profile_name) + + @SafeSlot(str) + def toggle_quick_select(self, profile_name: str): + enabled = is_quick_select(profile_name, namespace=self.profile_namespace) + set_quick_select(profile_name, not enabled, namespace=self.profile_namespace) + self.render_table() + if self.target_widget is not None: + self.target_widget._refresh_workspace_list() + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot() + def save_current_as_profile(self): + if self.target_widget is None: + QMessageBox.information( + self, + "Save Profile", + "No workspace is associated with this manager. Attach a workspace to save profiles.", + ) + return + + self.target_widget.save_profile_dialog() + # 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) + if current: + self._select_by_name(current) + self._show_profile_details(current) + + @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." + ) + 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 + + 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()) + + def resizeEvent(self, event): + super().resizeEvent(event) + name = self._current_selected_profile() + if not name: + return + pm = load_profile_screenshot(name, namespace=self.profile_namespace) + if pm is None or pm.isNull(): + return + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/__init__.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py new file mode 100644 index 000000000..3eea7237d --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from typing import Callable + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QComboBox, QSizePolicy + +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 + + +class ProfileComboBox(QComboBox): + """Custom combobox that displays icons for read-only profiles.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._quick_provider: Callable[[], list[str]] = list_quick_profiles + + def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: + self._quick_provider = provider + + 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. + """ + + current_text = active_profile or self.currentText() + self.blockSignals(True) + self.clear() + + quick_profiles = self._quick_provider() + quick_set = set(quick_profiles) + + items = list(quick_profiles) + if active_profile and active_profile not in quick_set: + items.insert(0, active_profile) + + for profile in items: + self.addItem(profile) + idx = self.count() - 1 + + # Reset any custom styling + self.setItemData(idx, None, Qt.ItemDataRole.FontRole) + self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole) + self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole) + + if active_profile and profile == active_profile: + tooltip = "Active workspace profile" + if profile not in quick_set: + font = QFont(self.font()) + font.setItalic(True) + font.setBold(True) + self.setItemData(idx, font, Qt.ItemDataRole.FontRole) + self.setItemData( + idx, self.palette().highlight().color(), Qt.ItemDataRole.ForegroundRole + ) + tooltip = "Active profile (not in quick select)" + self.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) + self.setCurrentIndex(idx) + elif profile not in quick_set: + self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole) + + # Restore selection if possible + 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 active_profile and active_profile not in quick_set: + self.setToolTip("Active profile is not in quick select") + else: + self.setToolTip("") + + +def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for AdvancedDockArea. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + # Workspace combo + combo = ProfileComboBox(parent=components.toolbar) + combo.setVisible(enable_tools) + components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) + + components.add_safe( + "save_workspace", + MaterialIconAction( + icon_name="save", + tooltip="Save Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + components.get_action("save_workspace").action.setVisible(enable_tools) + + components.add_safe( + "reset_default_workspace", + MaterialIconAction( + icon_name="undo", + tooltip="Refresh Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + components.get_action("reset_default_workspace").action.setVisible(enable_tools) + + components.add_safe( + "manage_workspaces", + MaterialIconAction( + icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar + ), + ) + components.get_action("manage_workspaces").action.setVisible(enable_tools) + + bundle = ToolbarBundle("workspace", components) + bundle.add_action("workspace_combo") + bundle.add_action("save_workspace") + bundle.add_action("reset_default_workspace") + bundle.add_action("manage_workspaces") + return bundle + + +class WorkspaceConnection(BundleConnection): + """ + Connection class for workspace actions in AdvancedDockArea. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + 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'.") + self._connected = False + + def connect(self): + self._connected = True + # 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) + + self.components.get_action("workspace_combo").widget.currentTextChanged.connect( + self.target_widget.load_profile + ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.connect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.connect(self.target_widget.show_workspace_manager) + + def disconnect(self): + if not self._connected: + return + # 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) + self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( + self.target_widget.load_profile + ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.disconnect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.disconnect(self.target_widget.show_workspace_manager) + self._connected = False + + @SafeSlot() + def _reset_workspace_to_default(self): + """ + Refreshes the current workspace. + """ + self.target_widget.restore_user_profile_from_default() diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index ad0b9ae1c..ca15a3ce0 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -3,9 +3,8 @@ from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, Qt, Signal from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget -from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.error_popups import SafeProperty @@ -24,7 +23,14 @@ class CollapsibleSection(QWidget): section_reorder_requested = Signal(str, str) # (source_title, target_title) - def __init__(self, parent=None, title="", indentation=10, show_add_button=False): + def __init__( + self, + parent=None, + title="", + indentation=10, + show_add_button=False, + tooltip: str | None = None, + ): super().__init__(parent=parent) self.title = title self.content_widget = None @@ -42,6 +48,8 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) # Create header button self.header_button = QPushButton() + # Apply theme variant for title styling + self.header_button.setProperty("variant", "title") self.header_button.clicked.connect(self.toggle_expanded) # Enable drag and drop for reordering @@ -50,6 +58,8 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) self.header_button.mouseMoveEvent = self._header_mouse_move_event self.header_button.dragEnterEvent = self._header_drag_enter_event self.header_button.dropEvent = self._header_drop_event + if tooltip: + self.header_button.setToolTip(tooltip) self.drag_start_position = None @@ -57,13 +67,16 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) header_layout.addWidget(self.header_button) header_layout.addStretch() - self.header_add_button = QPushButton() + # Add button in header (icon-only) + self.header_add_button = QToolButton() self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.header_add_button.setFixedSize(20, 20) + self.header_add_button.setFixedSize(28, 28) self.header_add_button.setToolTip("Add item") self.header_add_button.setVisible(show_add_button) + self.header_add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.header_add_button.setAutoRaise(True) - self.header_add_button.setIcon(material_icon("add", size=(20, 20))) + self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False)) header_layout.addWidget(self.header_add_button) self.main_layout.addLayout(header_layout) @@ -93,25 +106,6 @@ def _update_appearance(self): self.header_button.setIcon(icon) self.header_button.setText(self.title) - # Get theme colors - palette = get_theme_palette() - text_color = palette.text().color().name() - - self.header_button.setStyleSheet( - f""" - QPushButton {{ - font-weight: bold; - text-align: left; - margin: 0; - padding: 0px; - border: none; - background: transparent; - color: {text_color}; - icon-size: 20px 20px; - }} - """ - ) - def toggle_expanded(self): """Toggle the expanded state and update size policy""" self.expanded = not self.expanded diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py index b780cbdee..25bff357b 100644 --- a/bec_widgets/widgets/containers/explorer/explorer.py +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget): RPC = False PLUGIN = False - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) # Main layout self.main_layout = QVBoxLayout(self) diff --git a/bec_widgets/widgets/containers/explorer/explorer_delegate.py b/bec_widgets/widgets/containers/explorer/explorer_delegate.py new file mode 100644 index 000000000..7a8b41cbb --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/explorer_delegate.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView + +from bec_widgets.utils.colors import get_theme_palette + + +class ExplorerDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for the explorer""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QSortFilterProxyModel + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + tree_view = self.parent() + if not isinstance(tree_view, QTreeView): + return + + proxy_model = tree_view.model() + if not isinstance(proxy_model, self.target_model): + return + + actions = self.get_actions_for_current_item(proxy_model, index) + if actions: + self._draw_action_buttons(painter, option, actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + """Get actions for the current item based on its type""" + return None + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + actions = self.get_actions_for_current_item(model, index) + if not actions: + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py new file mode 100644 index 000000000..2546eb351 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -0,0 +1,382 @@ +import ast +import os +from pathlib import Path +from typing import Any + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QModelIndex, QRect, Qt, Signal +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate + +logger = bec_logger.logger + + +class MacroItemDelegate(ExplorerDelegate): + """Custom delegate to show action buttons on hover for macro functions""" + + def __init__(self, parent=None): + super().__init__(parent) + self.macro_actions: list[Any] = [] + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QStandardItemModel + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro functions""" + self.macro_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + self.macro_actions.clear() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + # Only show actions for macro functions (not directories) + item = index.model().itemFromIndex(index) + if not item or not item.data(Qt.ItemDataRole.UserRole): + return + + macro_info = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(macro_info, dict) or "function_name" not in macro_info: + return + + self.current_macro_info = macro_info + return self.macro_actions + + +class MacroTreeWidget(QWidget): + """A tree widget that displays macro functions from Python files""" + + macro_selected = Signal(str, str) # Function name, file path + macro_open_requested = Signal(str, str) # Function name, file path + + def __init__(self, parent=None): + super().__init__(parent) + + # Create layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Create tree view + self.tree = QTreeView() + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(True) + + # Disable editing to prevent renaming on double-click + self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers) + + # Enable mouse tracking for hover effects + self.tree.setMouseTracking(True) + + # Create model for macro functions + self.model = QStandardItemModel() + self.tree.setModel(self.model) + + # Create and set custom delegate + self.delegate = MacroItemDelegate(self.tree) + self.tree.setItemDelegate(self.delegate) + + # Add default open button for macros + action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self) + action.action.triggered.connect(self._on_macro_open_requested) + self.delegate.add_macro_action(action.action) + + # Apply BEC styling + self._apply_styling() + + # Macro specific properties + self.directory = None + + # Connect signals + self.tree.clicked.connect(self._on_item_clicked) + self.tree.doubleClicked.connect(self._on_item_double_clicked) + + # Install event filter for hover tracking + self.tree.viewport().installEventFilter(self) + + # Add to layout + layout.addWidget(self.tree) + + def _apply_styling(self): + """Apply styling to the tree widget""" + # Get theme colors for subtle tree lines + palette = get_theme_palette() + subtle_line_color = palette.mid().color() + subtle_line_color.setAlpha(80) + + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + + # pylint: disable=f-string-without-interpolation + tree_style = f""" + QTreeView {{ + border: none; + outline: 0; + show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} + }} + QTreeView::branch {{ + border-image: none; + background: transparent; + }} + + QTreeView::item {{ + border: none; + padding: 0px; + margin: 0px; + }} + QTreeView::item:hover {{ + background: palette(midlight); + border: none; + padding: 0px; + margin: 0px; + text-decoration: none; + }} + QTreeView::item:selected {{ + background: palette(highlight); + color: palette(highlighted-text); + }} + QTreeView::item:selected:hover {{ + background: palette(highlight); + }} + """ + + self.tree.setStyleSheet(tree_style) + + def eventFilter(self, obj, event): + """Handle mouse move events for hover tracking""" + # Early return if not the tree viewport + if obj != self.tree.viewport(): + return super().eventFilter(obj, event) + + if event.type() == event.Type.MouseMove: + index = self.tree.indexAt(event.pos()) + if index.isValid(): + self.delegate.set_hovered_index(index) + else: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + if event.type() == event.Type.Leave: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + return super().eventFilter(obj, event) + + def set_directory(self, directory): + """Set the macros directory and scan for macro functions""" + self.directory = directory + + # Early return if directory doesn't exist + if not directory or not os.path.exists(directory): + return + + self._scan_macro_functions() + + def _create_file_item(self, py_file: Path) -> QStandardItem | None: + """Create a file item with its functions + + Args: + py_file: Path to the Python file + + Returns: + QStandardItem representing the file, or None if no functions found + """ + # Skip files starting with underscore + if py_file.name.startswith("_"): + return None + + try: + functions = self._extract_functions_from_file(py_file) + if not functions: + return None + + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + return file_item + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + return None + + def _scan_macro_functions(self): + """Scan the directory for Python files and extract macro functions""" + self.model.clear() + self.model.setHorizontalHeaderLabels(["Macros"]) + + if not self.directory or not os.path.exists(self.directory): + return + + # Get all Python files in the directory + python_files = list(Path(self.directory).glob("*.py")) + + for py_file in python_files: + file_item = self._create_file_item(py_file) + if file_item: + self.model.appendRow(file_item) + + self.tree.expandAll() + + def _extract_functions_from_file(self, file_path: Path) -> dict: + """Extract function definitions from a Python file""" + functions = {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Parse the AST + tree = ast.parse(content) + + # Only get top-level function definitions + for node in tree.body: + if isinstance(node, ast.FunctionDef): + functions[node.name] = { + "line_number": node.lineno, + "docstring": ast.get_docstring(node) or "", + } + + except Exception as e: + logger.warning(f"Failed to parse {file_path}: {e}") + + return functions + + def _on_item_clicked(self, index: QModelIndex): + """Handle item clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info(f"Macro function selected: {function_name} in {file_path}") + self.macro_selected.emit(function_name, file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Handle item double-clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info( + f"Macro open requested via double-click: {function_name} in {file_path}" + ) + self.macro_open_requested.emit(function_name, file_path) + + def _on_macro_open_requested(self): + """Handle macro open action triggered""" + logger.info("Macro open requested") + # Early return if no hovered item + if not self.delegate.hovered_index.isValid(): + return + + macro_info = self.delegate.current_macro_info + if not macro_info or macro_info.get("type") != "function": + return + + function_name = macro_info.get("function_name") + file_path = macro_info.get("file_path") + if function_name and file_path: + self.macro_open_requested.emit(function_name, file_path) + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro items""" + self.delegate.add_macro_action(action) + + def clear_actions(self) -> None: + """Remove all actions from items""" + self.delegate.clear_actions() + + def refresh(self): + """Refresh the tree view""" + if self.directory is None: + return + self._scan_macro_functions() + + def refresh_file_item(self, file_path: str): + """Refresh a single file item by re-scanning its functions + + Args: + file_path: Path to the Python file to refresh + """ + if not file_path or not os.path.exists(file_path): + logger.warning(f"Cannot refresh file item: {file_path} does not exist") + return + + py_file = Path(file_path) + + # Find existing file item in the model + existing_item = None + existing_row = -1 + for row in range(self.model.rowCount()): + item = self.model.item(row) + if not item or not item.data(Qt.ItemDataRole.UserRole): + continue + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file): + existing_item = item + existing_row = row + break + + # Store expansion state if item exists + was_expanded = existing_item and self.tree.isExpanded(existing_item.index()) + + # Remove existing item if found + if existing_item and existing_row >= 0: + self.model.removeRow(existing_row) + + # Create new item using the helper method + new_item = self._create_file_item(py_file) + if new_item: + # Insert at the same position or append if it was a new file + insert_row = existing_row if existing_row >= 0 else self.model.rowCount() + self.model.insertRow(insert_row, new_item) + + # Restore expansion state + if was_expanded: + self.tree.expand(new_item.index()) + else: + self.tree.expand(new_item.index()) + + def expand_all(self): + """Expand all items in the tree""" + self.tree.expandAll() + + def collapse_all(self): + """Collapse all items in the tree""" + self.tree.collapseAll() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 86cec3493..68ff10353 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -2,32 +2,29 @@ from pathlib import Path from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QAction, QPainter -from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal +from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class FileItemDelegate(QStyledItemDelegate): +class FileItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover""" - def __init__(self, parent=None): - super().__init__(parent) - self.hovered_index = QModelIndex() - self.file_actions: list[QAction] = [] - self.dir_actions: list[QAction] = [] - self.button_rects: list[QRect] = [] - self.current_file_path = "" + def __init__(self, tree_widget): + super().__init__(tree_widget) + self.file_actions = [] + self.dir_actions = [] - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for files""" self.file_actions.append(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directories""" self.dir_actions.append(action) @@ -36,126 +33,18 @@ def clear_actions(self) -> None: self.file_actions.clear() self.dir_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - - tree_view = self.parent() - if not isinstance(tree_view, QTreeView): - return - - proxy_model = tree_view.model() - if not isinstance(proxy_model, QSortFilterProxyModel): - return - - source_index = proxy_model.mapToSource(index) - source_model = proxy_model.sourceModel() - if not isinstance(source_model, QFileSystemModel): - return - - is_dir = source_model.isDir(source_index) - file_path = source_model.filePath(source_index) - self.current_file_path = file_path - - # Choose appropriate actions based on item type - actions = self.dir_actions if is_dir else self.file_actions - if actions: - self._draw_action_buttons(painter, option, actions) - - def _draw_action_buttons(self, painter, option, actions: list[QAction]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Early return if not a proxy model + def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None: + """Get actions for the current item based on its type""" if not isinstance(model, QSortFilterProxyModel): - return super().editorEvent(event, model, option, index) + return None source_index = model.mapToSource(index) source_model = model.sourceModel() - - # Early return if not a file system model if not isinstance(source_model, QFileSystemModel): - return super().editorEvent(event, model, option, index) + return None is_dir = source_model.isDir(source_index) - actions = self.dir_actions if is_dir else self.file_actions - - # Check which button was clicked - visible_actions = [action for action in actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.dir_actions if is_dir else self.file_actions class ScriptTreeWidget(QWidget): @@ -229,12 +118,18 @@ def _apply_styling(self): subtle_line_color = palette.mid().color() subtle_line_color.setAlpha(80) + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + # pylint: disable=f-string-without-interpolation tree_style = f""" QTreeView {{ border: none; outline: 0; show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} }} QTreeView::branch {{ border-image: none; @@ -286,14 +181,14 @@ def eventFilter(self, obj, event): return super().eventFilter(obj, event) - def set_directory(self, directory): + def set_directory(self, directory: str) -> None: """Set the scripts directory""" - self.directory = directory - # Early return if directory doesn't exist - if not directory or not os.path.exists(directory): + if not directory or not isinstance(directory, str) or not os.path.exists(directory): return + self.directory = directory + root_index = self.model.setRootPath(directory) # Map the source model index to proxy model index proxy_root_index = self.proxy_model.mapFromSource(root_index) @@ -357,11 +252,11 @@ def _on_file_open_requested(self): self.file_open_requested.emit(file_path) - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for file items""" self.delegate.add_file_action(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directory items""" self.delegate.add_dir_action(action) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index e4d386525..55fdf1f1f 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEvent, QSize, Qt, QTimer @@ -19,9 +20,8 @@ import bec_widgets from bec_widgets.utils import UILoader from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import apply_theme, set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, @@ -35,7 +35,7 @@ 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.AA_DontUseNativeMenuBar, True) +QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True) class BECMainWindow(BECWidget, QMainWindow): @@ -44,24 +44,17 @@ class BECMainWindow(BECWidget, QMainWindow): SCAN_PROGRESS_WIDTH = 100 # px SCAN_PROGRESS_HEIGHT = 12 # px - def __init__( - self, - parent=None, - gui_id: str = None, - client=None, - window_title: str = "BEC", - *args, - **kwargs, - ): - super().__init__(parent=parent, gui_id=gui_id, **kwargs) + def __init__(self, parent=None, window_title: str = "BEC", **kwargs): + super().__init__(parent=parent, **kwargs) self.app = QApplication.instance() self.status_bar = self.statusBar() 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() + self.notification_broker = BECNotificationBroker(parent=self) self._nc_margin = 16 self._position_notification_centre() @@ -303,7 +296,7 @@ def _setup_menu_bar(self): ######################################## # Theme menu - theme_menu = menu_bar.addMenu("Theme") + theme_menu = menu_bar.addMenu("View") theme_group = QActionGroup(self) light_theme_action = QAction("Light Theme", self, checkable=True) @@ -320,18 +313,19 @@ def _setup_menu_bar(self): dark_theme_action.triggered.connect(lambda: self.change_theme("dark")) # Set the default theme - theme = self.app.theme.theme - if theme == "light": - light_theme_action.setChecked(True) - elif theme == "dark": - dark_theme_action.setChecked(True) + if hasattr(self.app, "theme") and self.app.theme: + theme_name = self.app.theme.theme.lower() + if "light" in theme_name: + light_theme_action.setChecked(True) + elif "dark" in theme_name: + dark_theme_action.setChecked(True) ######################################## # Help menu help_menu = menu_bar.addMenu("Help") - help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) - bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) + help_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) + bug_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) bec_docs = QAction("BEC Docs", self) bec_docs.setIcon(help_icon) @@ -394,7 +388,7 @@ def change_theme(self, theme: str): Args: theme(str): Either "light" or "dark". """ - set_theme(theme) # emits theme_updated and applies palette globally + apply_theme(theme) # emits theme_updated and applies palette globally def event(self, event): if event.type() == QEvent.Type.StatusTip: @@ -402,21 +396,6 @@ def event(self, event): return super().event(event) def cleanup(self): - central_widget = self.centralWidget() - if central_widget is not None: - central_widget.close() - central_widget.deleteLater() - if not isinstance(central_widget, BECWidget): - # if the central widget is not a BECWidget, we need to call the cleanup method - # of all widgets whose parent is the current BECMainWindow - children = self.findChildren(BECWidget) - for child in children: - ancestor = WidgetHierarchy._get_becwidget_ancestor(child) - if ancestor is self: - child.cleanup() - child.close() - child.deleteLater() - # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): self._client_info_expire_timer.stop() diff --git a/bec_widgets/widgets/containers/qt_ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/__init__.py new file mode 100644 index 000000000..aa837994c --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/__init__.py @@ -0,0 +1 @@ +from PySide6QtAds import * diff --git a/bec_widgets/widgets/containers/qt_ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/__init__.pyi new file mode 100644 index 000000000..dfc1232f4 --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/__init__.pyi @@ -0,0 +1,989 @@ +from __future__ import annotations + +import collections +import enum +import typing + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Signal + +from bec_widgets.widgets.containers.qt_ads import ads +from bec_widgets.widgets.containers.qt_ads.ads import * + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class CAutoHideDockContainer(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + area: SideBarLocation, + parent: typing.Optional["CDockContainerWidget"], + ) -> None: ... + def moveToNewSideBarLocation(self, a0: SideBarLocation) -> None: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def resetToInitialDockWidgetSize(self) -> None: ... + def setSize(self, Size: int) -> None: ... + def toggleCollapseState(self) -> None: ... + def collapseView(self, Enable: bool) -> None: ... + def toggleView(self, Enable: bool) -> None: ... + def cleanupAndDelete(self) -> None: ... + def moveContentsToParent(self) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setSideBarLocation(self, SideBarLocation: SideBarLocation) -> None: ... + def sideBarLocation(self) -> SideBarLocation: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def tabIndex(self) -> int: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def autoHideTab(self) -> typing.Optional["CAutoHideTab"]: ... + def autoHideSideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def updateSize(self) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def leaveEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CAutoHideSideBar(QtWidgets.QScrollArea): + def __init__( + self, parent: typing.Optional["CDockContainerWidget"], area: "SideBarLocation" + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def setSpacing(self, Spacing: int) -> None: ... + def spacing(self) -> int: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def hasVisibleTabs(self) -> bool: ... + def visibleTabCount(self) -> int: ... + def count(self) -> int: ... + def indexOfTab(self, Tab: "CAutoHideTab") -> int: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, index: int) -> typing.Optional["CAutoHideTab"]: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def addAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"], Index: int + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def insertDockWidget( + self, Index: int, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def removeTab(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def insertTab(self, Index: int, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CPushButton(QtWidgets.QPushButton): + class Orientation(enum.Enum): + Horizontal = ... + VerticalTopToBottom = ... + VerticalBottomToTop = ... + + def __init__(self) -> None: ... + def setButtonOrientation(self, orientation: "CPushButton.Orientation") -> None: ... + def buttonOrientation(self) -> "CPushButton.Orientation": ... + def sizeHint(self) -> QtCore.QSize: ... + +class CAutoHideTab(CPushButton): + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def unpinDockWidget(self) -> None: ... + def setDockWidgetFloating(self) -> None: ... + def tabIndex(self) -> int: ... + def sideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def iconOnly(self) -> bool: ... + def setDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def isActiveTab(self) -> bool: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def setOrientation(self, Orientation: QtCore.Qt.Orientation) -> None: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def updateStyle(self) -> None: ... + def dragLeaveEvent(self, ev: typing.Optional[QtGui.QDragLeaveEvent]) -> None: ... + def dragEnterEvent(self, ev: typing.Optional[QtGui.QDragEnterEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def removeFromSideBar(self) -> None: ... + def setSideBar(self, SideTabBar: typing.Optional["CAutoHideSideBar"]) -> None: ... + +class CDockWidget(QtWidgets.QFrame): + class eToggleViewActionMode(enum.Enum): + ActionModeToggle = ... + ActionModeShow = ... + + class eMinimumSizeHintMode(enum.Enum): + MinimumSizeHintFromDockWidget = ... + MinimumSizeHintFromContent = ... + MinimumSizeHintFromDockWidgetMinimumSize = ... + MinimumSizeHintFromContentMinimumSize = ... + + class eInsertMode(enum.Enum): + AutoScrollArea = ... + ForceScrollArea = ... + ForceNoScrollArea = ... + + class eToolBarStyleSource(enum.Enum): + ToolBarStyleFromDockManager = ... + ToolBarStyleFromDockWidget = ... + + class eState(enum.Enum): + StateHidden = ... + StateDocked = ... + StateFloating = ... + + class DockWidgetFeature(enum.Enum): + DockWidgetClosable = ... + DockWidgetMovable = ... + DockWidgetFloatable = ... + DockWidgetDeleteOnClose = ... + CustomCloseHandling = ... + DockWidgetFocusable = ... + DockWidgetForceCloseWithArea = ... + NoTab = ... + DeleteContentOnClose = ... + DockWidgetPinnable = ... + DefaultDockWidgetFeatures = ... + AllDockWidgetFeatures = ... + DockWidgetAlwaysCloseAndDelete = ... + GloballyLockableFeatures = ... + NoDockWidgetFeatures = ... + + @typing.overload + def __init__( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + @typing.overload + def __init__( + self, + manager: typing.Optional["CDockManager"], + title: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + featuresChanged: typing.ClassVar[Signal] + visibilityChanged: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + topLevelChanged: typing.ClassVar[Signal] + titleChanged: typing.ClassVar[Signal] + closed: typing.ClassVar[Signal] + viewToggled: typing.ClassVar[Signal] + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def showNormal(self) -> None: ... + def showFullScreen(self) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def closeDockWidget(self) -> None: ... + def deleteDockWidget(self) -> None: ... + def setFloating(self) -> None: ... + def raise_(self) -> None: ... + def setAsCurrentTab(self) -> None: ... + def toggleView(self, Open: bool = ...) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isCurrentTab(self) -> bool: ... + def isTabbed(self) -> bool: ... + def isFullScreen(self) -> bool: ... + def setTabToolTip(self, text: typing.Optional[str]) -> None: ... + def titleBarActions(self) -> list[QtGui.QAction]: ... + def setTitleBarActions(self, actions: collections.abc.Iterable[QtGui.QAction]) -> None: ... + def toolBarIconSize(self, State: "CDockWidget.eState") -> QtCore.QSize: ... + def setToolBarIconSize(self, IconSize: QtCore.QSize, State: "CDockWidget.eState") -> None: ... + def toolBarStyle(self, State: "CDockWidget.eState") -> QtCore.Qt.ToolButtonStyle: ... + def setToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: "CDockWidget.eState" + ) -> None: ... + def toolBarStyleSource(self) -> "CDockWidget.eToolBarStyleSource": ... + def setToolBarStyleSource(self, Source: "CDockWidget.eToolBarStyleSource") -> None: ... + def setToolBar(self, ToolBar: typing.Optional[QtWidgets.QToolBar]) -> None: ... + def createDefaultToolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def toolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def isCentralWidget(self) -> bool: ... + def minimumSizeHintMode(self) -> "CDockWidget.eMinimumSizeHintMode": ... + def setMinimumSizeHintMode(self, Mode: "CDockWidget.eMinimumSizeHintMode") -> None: ... + def setToggleViewActionMode(self, Mode: "CDockWidget.eToggleViewActionMode") -> None: ... + def setToggleViewAction(self, action: typing.Optional[QtGui.QAction]) -> None: ... + def toggleViewAction(self) -> typing.Optional[QtGui.QAction]: ... + def isClosed(self) -> bool: ... + def isInFloatingContainer(self) -> bool: ... + def isFloating(self) -> bool: ... + def autoHideLocation(self) -> "SideBarLocation": ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def isAutoHide(self) -> bool: ... + def setSideTabWidget(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def sideTabWidget(self) -> typing.Optional["CAutoHideTab"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def floatingDockContainer(self) -> typing.Optional["CFloatingDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def setFeature(self, flag: "CDockWidget.DockWidgetFeature", on: bool) -> None: ... + def setFeatures(self, features: "CDockWidget.DockWidgetFeature") -> None: ... + def tabWidget(self) -> typing.Optional["CDockWidgetTab"]: ... + def widget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def takeWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def setWidget( + self, + widget: typing.Optional[QtWidgets.QWidget], + InsertMode: "CDockWidget.eInsertMode" = ..., + ) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def closeDockWidgetInternal(self, ForceClose: bool = ...) -> bool: ... + def toggleViewInternal(self, Open: bool) -> None: ... + def setClosedState(self, Closed: bool) -> None: ... + def emitTopLevelChanged(self, Floating: bool) -> None: ... + @staticmethod + def emitTopLevelEventForWidget( + TopLevelDockWidget: typing.Optional["CDockWidget"], Floating: bool + ) -> None: ... + def flagAsUnassigned(self) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setToggleViewActionChecked(self, Checked: bool) -> None: ... + def setDockArea(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setDockManager(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + +class CDockAreaTabBar(QtWidgets.QScrollArea): + def __init__(self, parent: typing.Optional["CDockAreaWidget"]) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + tabInserted: typing.ClassVar[Signal] + removingTab: typing.ClassVar[Signal] + tabMoved: typing.ClassVar[Signal] + tabOpened: typing.ClassVar[Signal] + tabClosed: typing.ClassVar[Signal] + tabCloseRequested: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + def closeTab(self, Index: int) -> None: ... + def setCurrentIndex(self, Index: int) -> None: ... + def areTabsOverflowing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isTabOpen(self, Index: int) -> bool: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, Index: int) -> typing.Optional["CDockWidgetTab"]: ... + def currentTab(self) -> typing.Optional["CDockWidgetTab"]: ... + def currentIndex(self) -> int: ... + def count(self) -> int: ... + def removeTab(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def insertTab(self, Index: int, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def wheelEvent(self, Event: typing.Optional[QtGui.QWheelEvent]) -> None: ... + +class CSpacerWidget(QtWidgets.QWidget): + def __init__(self, Parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sizeHint(self) -> QtCore.QSize: ... + +class CTitleBarButton(QtWidgets.QToolButton): + def __init__( + self, + ShowInTitleBar: bool, + HideWhenDisabled: bool, + ButtonId: "TitleBarButton", + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + def event(self, ev: typing.Optional[QtCore.QEvent]) -> bool: ... + def isInAutoHideArea(self) -> bool: ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def buttonId(self) -> "TitleBarButton": ... + def setShowInTitleBar(self, a0: bool) -> None: ... + def setVisible(self, a0: bool) -> None: ... + +class CDockAreaTitleBar(QtWidgets.QFrame): + def __init__(self, parent: typing.Optional[CDockAreaWidget]) -> None: ... + + tabBarClicked: typing.ClassVar[Signal] + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def isAutoHide(self) -> bool: ... + def showAutoHideControls(self, Show: bool) -> None: ... + def setAreaFloating(self) -> None: ... + def titleBarButtonToolTip(self, Button: "TitleBarButton") -> str: ... + def indexOf(self, widget: typing.Optional[QtWidgets.QWidget]) -> int: ... + def insertWidget(self, index: int, widget: typing.Optional[QtWidgets.QWidget]) -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def updateDockWidgetActionsButtons(self) -> None: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def autoHideTitleLabel(self) -> typing.Optional["CElidingLabel"]: ... + def button(self, which: "TitleBarButton") -> typing.Optional["CTitleBarButton"]: ... + def tabBar(self) -> typing.Optional["CDockAreaTabBar"]: ... + def markTabsMenuOutdated(self) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def contextMenuEvent(self, event: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CDockAreaWidget(QtWidgets.QFrame): + class eDockAreaFlag(enum.Enum): + HideSingleWidgetTitleBar = ... + DefaultFlags = ... + + def __init__( + self, + DockManager: typing.Optional[CDockManager], + parent: typing.Optional[CDockContainerWidget], + ) -> None: ... + + viewToggled: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + def setFloating(self) -> None: ... + def closeOtherAreas(self) -> None: ... + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def closeArea(self) -> None: ... + def setCurrentIndex(self, index: int) -> None: ... + def isTopLevelArea(self) -> bool: ... + def containsCentralWidget(self) -> bool: ... + def isCentralWidgetArea(self) -> bool: ... + def setDockAreaFlag(self, Flag: "CDockAreaWidget.eDockAreaFlag", On: bool) -> None: ... + def setDockAreaFlags(self, Flags: "CDockAreaWidget.eDockAreaFlag") -> None: ... + def dockAreaFlags(self) -> "CDockAreaWidget.eDockAreaFlag": ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def titleBarButton( + self, which: "TitleBarButton" + ) -> typing.Optional[QtWidgets.QAbstractButton]: ... + def features(self, Mode: "eBitwiseOperator" = ...) -> "CDockWidget.DockWidgetFeature": ... + @staticmethod + def restoreState( + Stream: "CDockingStateReader", + Testing: bool, + ParentContainer: typing.Optional["CDockContainerWidget"], + ) -> typing.Tuple[bool, typing.Optional["CDockAreaWidget"]]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def currentDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def indexOfFirstOpenDockWidget(self) -> int: ... + def currentIndex(self) -> int: ... + def dockWidget(self, Index: int) -> typing.Optional["CDockWidget"]: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openDockWidgetsCount(self) -> int: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def dockWidgetsCount(self) -> int: ... + def contentAreaGeometry(self) -> QtCore.QRect: ... + def titleBarGeometry(self) -> QtCore.QRect: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def setAutoHideDockContainer(self, a0: typing.Optional["CAutoHideDockContainer"]) -> None: ... + def isAutoHide(self) -> bool: ... + def parentSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def toggleView(self, Open: bool) -> None: ... + def updateTitleBarButtonVisibility(self, IsTopLevel: bool) -> None: ... + def markTitleBarMenuOutdated(self) -> None: ... + def internalSetCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def updateTitleBarVisibility(self) -> None: ... + def hideAreaWithNoVisibleContent(self) -> None: ... + def index(self, DockWidget: typing.Optional["CDockWidget"]) -> int: ... + def nextOpenDockWidget( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockWidget"]: ... + def toggleDockWidgetView( + self, DockWidget: typing.Optional["CDockWidget"], Open: bool + ) -> None: ... + def removeDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def insertDockWidget( + self, index: int, DockWidget: typing.Optional["CDockWidget"], Activate: bool = ... + ) -> None: ... + +class CDockContainerWidget(QtWidgets.QFrame): + def __init__( + self, + DockManager: typing.Optional["CDockManager"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + dockAreaViewToggled: typing.ClassVar[Signal] + dockAreasRemoved: typing.ClassVar[Signal] + autoHideWidgetCreated: typing.ClassVar[Signal] + dockAreasAdded: typing.ClassVar[Signal] + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def contentRectGlobal(self) -> QtCore.QRect: ... + def contentRect(self) -> QtCore.QRect: ... + def autoHideWidgets(self) -> list["CAutoHideDockContainer"]: ... + def autoHideSideBar(self, area: "SideBarLocation") -> typing.Optional["CAutoHideSideBar"]: ... + def closeOtherAreas(self, KeepOpenArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def floatingWidget(self) -> typing.Optional["CFloatingDockContainer"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def dumpLayout(self) -> None: ... + def isFloating(self) -> bool: ... + def visibleDockAreaCount(self) -> int: ... + def dockAreaCount(self) -> int: ... + def hasTopLevelDockWidget(self) -> bool: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openedDockAreas(self) -> list["CDockAreaWidget"]: ... + def dockArea(self, Index: int) -> typing.Optional["CDockAreaWidget"]: ... + def dockAreaAt(self, GlobalPos: QtCore.QPoint) -> typing.Optional["CDockAreaWidget"]: ... + def isInFrontOf(self, Other: typing.Optional["CDockContainerWidget"]) -> bool: ... + def zOrderIndex(self) -> int: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def handleAutoHideWidgetEvent( + self, e: typing.Optional[QtCore.QEvent], w: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def registerAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def updateSplitterHandles(self, splitter: typing.Optional[QtWidgets.QSplitter]) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockArea(self) -> typing.Optional["CDockAreaWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def lastAddedDockAreaWidget( + self, area: "DockWidgetArea" + ) -> typing.Optional["CDockAreaWidget"]: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def removeAllDockAreas(self) -> list[CDockAreaWidget]: ... + def removeDockArea(self, area: typing.Optional["CDockAreaWidget"]) -> None: ... + def addDockArea( + self, DockAreaWidget: typing.Optional["CDockAreaWidget"], area: "DockWidgetArea" = ... + ) -> None: ... + def dropWidget( + self, + Widget: typing.Optional[QtWidgets.QWidget], + DropArea: "DockWidgetArea", + TargetAreaWidget: typing.Optional["CDockAreaWidget"], + TabIndex: int = ..., + ) -> None: ... + def dropFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"], TargetPos: QtCore.QPoint + ) -> None: ... + def createRootSplitter(self) -> None: ... + def createAndSetupAutoHideContainer( + self, + area: "SideBarLocation", + DockWidget: typing.Optional["CDockWidget"], + TabIndex: int = ..., + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def rootSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + +class CDockingStateReader(QtCore.QXmlStreamReader): + def __init__(self) -> None: ... + def fileVersion(self) -> int: ... + def setFileVersion(self, FileVersion: int) -> None: ... + +class CDockFocusController(QtCore.QObject): + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + def setDockWidgetFocused(self, focusedNow: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabPressed(self, Value: bool) -> None: ... + def clearDockWidgetFocus(self, dockWidget: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabFocused(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + +class CDockManager(CDockContainerWidget): + class eConfigParam(enum.Enum): + AutoHideOpenOnDragHoverDelay_ms = ... + ConfigParamCount = ... + + class eAutoHideFlag(enum.Enum): + AutoHideFeatureEnabled = ... + DockAreaHasAutoHideButton = ... + AutoHideButtonTogglesArea = ... + AutoHideButtonCheckable = ... + AutoHideSideBarsIconOnly = ... + AutoHideShowOnMouseOver = ... + AutoHideCloseButtonCollapsesDock = ... + AutoHideHasCloseButton = ... + AutoHideHasMinimizeButton = ... + AutoHideOpenOnDragHover = ... + AutoHideCloseOnOutsideMouseClick = ... + DefaultAutoHideConfig = ... + + class eConfigFlag(enum.Enum): + ActiveTabHasCloseButton = ... + DockAreaHasCloseButton = ... + DockAreaCloseButtonClosesTab = ... + OpaqueSplitterResize = ... + XmlAutoFormattingEnabled = ... + XmlCompressionEnabled = ... + TabCloseButtonIsToolButton = ... + AllTabsHaveCloseButton = ... + RetainTabSizeWhenCloseButtonHidden = ... + DragPreviewIsDynamic = ... + DragPreviewShowsContentPixmap = ... + DragPreviewHasWindowFrame = ... + AlwaysShowTabs = ... + DockAreaHasUndockButton = ... + DockAreaHasTabsMenuButton = ... + DockAreaHideDisabledButtons = ... + DockAreaDynamicTabsMenuButtonVisibility = ... + FloatingContainerHasWidgetTitle = ... + FloatingContainerHasWidgetIcon = ... + HideSingleCentralWidgetTitleBar = ... + FocusHighlighting = ... + EqualSplitOnInsertion = ... + FloatingContainerForceNativeTitleBar = ... + FloatingContainerForceQWidgetTitleBar = ... + MiddleMouseButtonClosesTab = ... + DisableTabTextEliding = ... + ShowTabTextOnlyForActiveTab = ... + DoubleClickUndocksWidget = ... + DefaultDockAreaButtons = ... + DefaultBaseConfig = ... + DefaultOpaqueConfig = ... + DefaultNonOpaqueConfig = ... + NonOpaqueWithWindowFrame = ... + + class eViewMenuInsertionOrder(enum.Enum): + MenuSortedByInsertion = ... + MenuAlphabeticallySorted = ... + + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + + focusedDockWidgetChanged: typing.ClassVar[Signal] + dockWidgetRemoved: typing.ClassVar[Signal] + dockWidgetAboutToBeRemoved: typing.ClassVar[Signal] + dockWidgetAdded: typing.ClassVar[Signal] + dockAreaCreated: typing.ClassVar[Signal] + floatingWidgetCreated: typing.ClassVar[Signal] + perspectiveOpened: typing.ClassVar[Signal] + openingPerspective: typing.ClassVar[Signal] + stateRestored: typing.ClassVar[Signal] + restoringState: typing.ClassVar[Signal] + perspectivesRemoved: typing.ClassVar[Signal] + perspectiveListLoaded: typing.ClassVar[Signal] + perspectiveListChanged: typing.ClassVar[Signal] + def hideManagerAndFloatingWidgets(self) -> None: ... + def setDockWidgetFocused(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def openPerspective(self, PerspectiveName: typing.Optional[str]) -> None: ... + def endLeavingMinimizedState(self) -> None: ... + def lockDockWidgetFeaturesGlobally( + self, Features: CDockWidget.DockWidgetFeature = ... + ) -> None: ... + def globallyLockedDockWidgetFeatures(self) -> CDockWidget.DockWidgetFeature: ... + def dockWidgetToolBarIconSize(self, State: CDockWidget.eState) -> QtCore.QSize: ... + def setDockWidgetToolBarIconSize( + self, IconSize: QtCore.QSize, State: CDockWidget.eState + ) -> None: ... + def dockWidgetToolBarStyle(self, State: CDockWidget.eState) -> QtCore.Qt.ToolButtonStyle: ... + def setDockWidgetToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: CDockWidget.eState + ) -> None: ... + @staticmethod + def floatingContainersTitle() -> str: ... + @staticmethod + def setFloatingContainersTitle(Title: typing.Optional[str]) -> None: ... + def setSplitterSizes( + self, + ContainedArea: typing.Optional["CDockAreaWidget"], + sizes: collections.abc.Iterable[int], + ) -> None: ... + def splitterSizes(self, ContainedArea: typing.Optional["CDockAreaWidget"]) -> list[int]: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + @staticmethod + def startDragDistance() -> int: ... + def isLeavingMinimizedState(self) -> bool: ... + def isRestoringState(self) -> bool: ... + def setViewMenuInsertionOrder(self, Order: "CDockManager.eViewMenuInsertionOrder") -> None: ... + def viewMenu(self) -> typing.Optional[QtWidgets.QMenu]: ... + def addToggleViewActionToMenu( + self, + ToggleViewAction: typing.Optional[QtGui.QAction], + Group: typing.Optional[str] = ..., + GroupIcon: QtGui.QIcon = ..., + ) -> typing.Optional[QtGui.QAction]: ... + def setCentralWidget( + self, widget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def centralWidget(self) -> typing.Optional["CDockWidget"]: ... + def loadPerspectives(self, Settings: QtCore.QSettings) -> None: ... + def savePerspectives(self, Settings: QtCore.QSettings) -> None: ... + def perspectiveNames(self) -> list[str]: ... + def removePerspectives(self, Names: collections.abc.Iterable[typing.Optional[str]]) -> None: ... + def removePerspective(self, Name: typing.Optional[str]) -> None: ... + def addPerspective(self, UniquePrespectiveName: typing.Optional[str]) -> None: ... + def restoreState( + self, + state: typing.Union[QtCore.QByteArray, bytes, bytearray, memoryview], + version: int = ..., + ) -> bool: ... + def saveState(self, version: int = ...) -> QtCore.QByteArray: ... + def zOrderIndex(self) -> int: ... + def floatingWidgets(self) -> list["CFloatingDockContainer"]: ... + def dockContainers(self) -> list["CDockContainerWidget"]: ... + def dockWidgetsMap(self) -> dict[str, CDockWidget]: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def findDockWidget( + self, ObjectName: typing.Optional[str] + ) -> typing.Optional["CDockWidget"]: ... + def addDockWidgetFloating( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CFloatingDockContainer"]: ... + def addDockWidgetTabToArea( + self, + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"], + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidgetTab( + self, area: "DockWidgetArea", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def addAutoHideDockWidgetToContainer( + self, + Location: "SideBarLocation", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"], + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addAutoHideDockWidget( + self, Location: "SideBarLocation", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addDockWidgetToContainer( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"] = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + @staticmethod + def iconProvider() -> "CIconProvider": ... + @staticmethod + def configParam(Param: "CDockManager.eConfigParam", Default: typing.Any) -> typing.Any: ... + @staticmethod + def setConfigParam(Param: "CDockManager.eConfigParam", Value: typing.Any) -> None: ... + @staticmethod + def testAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag") -> bool: ... + @staticmethod + def setAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag", On: bool = ...) -> None: ... + @staticmethod + def setAutoHideConfigFlags(Flags: "CDockManager.eAutoHideFlag") -> None: ... + @staticmethod + def autoHideConfigFlags() -> "CDockManager.eAutoHideFlag": ... + @staticmethod + def testConfigFlag(Flag: "CDockManager.eConfigFlag") -> bool: ... + @staticmethod + def setConfigFlag(Flag: "CDockManager.eConfigFlag", On: bool = ...) -> None: ... + @staticmethod + def setConfigFlags(Flags: "CDockManager.eConfigFlag") -> None: ... + @staticmethod + def configFlags() -> "CDockManager.eConfigFlag": ... + def setComponentsFactory(self, Factory: typing.Optional["CDockComponentsFactory"]) -> None: ... + def componentsFactory(self) -> typing.Optional["CDockComponentsFactory"]: ... + def createDockWidget( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> typing.Optional["CDockWidget"]: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def dockAreaOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def containerOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def removeDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def registerDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def removeFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def registerFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + +class CDockOverlay(QtWidgets.QFrame): + class eMode(enum.Enum): + ModeDockAreaOverlay = ... + ModeContainerOverlay = ... + + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget], Mode: "CDockOverlay.eMode" = ... + ) -> None: ... + def hideEvent(self, e: typing.Optional[QtGui.QHideEvent]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def dropOverlayRect(self) -> QtCore.QRect: ... + def dropPreviewEnabled(self) -> bool: ... + def enableDropPreview(self, Enable: bool) -> None: ... + def hideOverlay(self) -> None: ... + def showOverlay(self, target: typing.Optional[QtWidgets.QWidget]) -> "DockWidgetArea": ... + def visibleDropAreaUnderCursor(self) -> "DockWidgetArea": ... + def tabIndexUnderCursor(self) -> int: ... + def dropAreaUnderCursor(self) -> "DockWidgetArea": ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedArea(self, area: "DockWidgetArea", Enable: bool) -> None: ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + +class CDockOverlayCross(QtWidgets.QWidget): + class eIconColor(enum.Enum): + FrameColor = ... + WindowBackgroundColor = ... + OverlayColor = ... + ArrowColor = ... + ShadowColor = ... + + def __init__(self, overlay: typing.Optional["CDockOverlay"]) -> None: ... + def setIconColors(self, Colors: typing.Optional[str]) -> None: ... + def updatePosition(self) -> None: ... + def reset(self) -> None: ... + def updateOverlayIcons(self) -> None: ... + def setupOverlayCross(self, Mode: "CDockOverlay.eMode") -> None: ... + def cursorLocation(self) -> "DockWidgetArea": ... + def setIconColor( + self, + ColorIndex: "CDockOverlayCross.eIconColor", + Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int], + ) -> None: ... + def setAreaWidgets(self, widgets: dict["DockWidgetArea", QtWidgets.QWidget]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def setIconShadowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconArrowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconOverlayColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconBackgroundColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconFrameColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + @typing.overload + def iconColor(self) -> QtGui.QColor: ... + @typing.overload + def iconColor(self, ColorIndex: "CDockOverlayCross.eIconColor") -> QtGui.QColor: ... + def iconColors(self) -> str: ... + +class CDockSplitter(QtWidgets.QSplitter): + @typing.overload + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + @typing.overload + def __init__( + self, orientation: QtCore.Qt.Orientation, parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + def isResizingWithContainer(self) -> bool: ... + def lastWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def firstWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def hasVisibleContent(self) -> bool: ... + +class CDockWidgetTab(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + moved: typing.ClassVar[Signal] + closeOtherTabsRequested: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + activeTabChanged: typing.ClassVar[Signal] + def setVisible(self, visible: bool) -> None: ... + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def dragState(self) -> "eDragState": ... + def setIconSize(self, Size: QtCore.QSize) -> None: ... + def iconSize(self) -> QtCore.QSize: ... + def updateStyle(self) -> None: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isClosable(self) -> bool: ... + def isTitleElided(self) -> bool: ... + def setText(self, title: typing.Optional[str]) -> None: ... + def text(self) -> str: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setDockAreaWidget(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setActiveTab(self, active: bool) -> None: ... + def isActiveTab(self) -> bool: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CElidingLabel(QtWidgets.QLabel): + @typing.overload + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget] = ..., f: QtCore.Qt.WindowType = ... + ) -> None: ... + @typing.overload + def __init__( + self, + text: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + f: QtCore.Qt.WindowType = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + doubleClicked: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + def text(self) -> str: ... + def setText(self, text: typing.Optional[str]) -> None: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isElided(self) -> bool: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def elideMode(self) -> QtCore.Qt.TextElideMode: ... + def mouseDoubleClickEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def mouseReleaseEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class IFloatingWidget: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: "IFloatingWidget") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDockContainer(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + @typing.overload + def __init__(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + @typing.overload + def __init__(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def finishDropOperation(self) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def hasTopLevelDockWidget(self) -> bool: ... + def isClosable(self) -> bool: ... + def startDragging( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def moveEvent(self, event: typing.Optional[QtGui.QMoveEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def hideEvent(self, event: typing.Optional[QtGui.QHideEvent]) -> None: ... + def closeEvent(self, event: typing.Optional[QtGui.QCloseEvent]) -> None: ... + def changeEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def updateWindowTitle(self) -> None: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def moveFloating(self) -> None: ... + def initFloatingGeometry( + self, DragStartMousePos: QtCore.QPoint, Size: QtCore.QSize + ) -> None: ... + def deleteContent(self) -> None: ... + def finishDragging(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDragPreview(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__( + self, + Content: typing.Optional[QtWidgets.QWidget], + parent: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockWidget"]) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockAreaWidget"]) -> None: ... + + draggingCanceled: typing.ClassVar[Signal] + def cleanupAutoHideContainerWidget(self, ContainerDropArea: "DockWidgetArea") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + +class CIconProvider: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: CIconProvider) -> None: ... + def registerCustomIcon(self, IconId: "eIcon", icon: QtGui.QIcon) -> None: ... + def customIcon(self, IconId: "eIcon") -> QtGui.QIcon: ... + +class CResizeHandle(QtWidgets.QFrame): + def __init__( + self, HandlePosition: QtCore.Qt.Edge, parent: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def opaqueResize(self) -> bool: ... + def setOpaqueResize(self, opaque: bool = ...) -> None: ... + def setMaxResizeSize(self, MaxSize: int) -> None: ... + def setMinResizeSize(self, MinSize: int) -> None: ... + def isResizing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def handlePostion(self) -> QtCore.Qt.Edge: ... + def setHandlePosition(self, HandlePosition: QtCore.Qt.Edge) -> None: ... + def mouseReleaseEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/ads/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi new file mode 100644 index 000000000..30b3a90af --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi @@ -0,0 +1,58 @@ +from __future__ import annotations + +import enum + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class SideBarLocation(enum.Enum): + SideBarTop = ... + SideBarLeft = ... + SideBarRight = ... + SideBarBottom = ... + SideBarNone = ... + +class eBitwiseOperator(enum.Enum): + BitwiseAnd = ... + BitwiseOr = ... + +class eIcon(enum.Enum): + TabCloseIcon = ... + AutoHideIcon = ... + DockAreaMenuIcon = ... + DockAreaUndockIcon = ... + DockAreaCloseIcon = ... + DockAreaMinimizeIcon = ... + IconCount = ... + +class eDragState(enum.Enum): + DraggingInactive = ... + DraggingMousePressed = ... + DraggingTab = ... + DraggingFloatingWidget = ... + +class TitleBarButton(enum.Enum): + TitleBarButtonTabsMenu = ... + TitleBarButtonUndock = ... + TitleBarButtonClose = ... + TitleBarButtonAutoHide = ... + TitleBarButtonMinimize = ... + +class eTabIndex(enum.Enum): + TabDefaultInsertIndex = ... + TabInvalidIndex = ... + +class DockWidgetArea(enum.Enum): + NoDockWidgetArea = ... + LeftDockWidgetArea = ... + RightDockWidgetArea = ... + TopDockWidgetArea = ... + BottomDockWidgetArea = ... + CenterDockWidgetArea = ... + LeftAutoHideArea = ... + RightAutoHideArea = ... + TopAutoHideArea = ... + BottomAutoHideArea = ... + InvalidDockWidgetArea = ... + OuterDockAreas = ... + AutoHideDockAreas = ... + AllDockAreas = ... diff --git a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py index c14bb062e..4a9585dc2 100644 --- a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py +++ b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py @@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "cancel" - RPC = True + RPC = False def __init__( self, @@ -38,9 +38,6 @@ def __init__( else: self.button = QPushButton() self.button.setText("Abort") - self.button.setStyleSheet( - "background-color: #666666; color: white; font-weight: bold; font-size: 12px;" - ) self.button.clicked.connect(self.abort_scan) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py index caea1cc71..dc468a318 100644 --- a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py +++ b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py @@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "restart_alt" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) diff --git a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py index 7d0456ecc..218fa2e98 100644 --- a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py +++ b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py @@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "dangerous" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) @@ -31,9 +31,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=F self.button = QPushButton() self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.button.setText("Stop") - self.button.setStyleSheet( - f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;" - ) + self.button.setProperty("variant", "danger") self.button.clicked.connect(self.stop_scan) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 78cd5fa21..f7cfaf4cc 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -7,12 +7,12 @@ from bec_lib.device import Positioner from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import Qt, Signal from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import QDoubleSpinBox from bec_widgets.utils import UILoader -from bec_widgets.utils.colors import get_accent_colors, set_theme +from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( @@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase): PLUGIN = True RPC = True - USER_ACCESS = ["set_positioner", "screenshot"] + USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"] device_changed = Signal(str, str) # Signal emitted to inform listeners about a position update position_update = Signal(float) @@ -49,6 +49,7 @@ def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs self._device = "" self._limits = None + self._hide_device_selection = False if self.current_path == "": self.current_path = os.path.dirname(__file__) @@ -65,6 +66,13 @@ def init_ui(self): 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 @@ -114,11 +122,12 @@ def device(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button.setVisible(not value) @SafeSlot(bool) @@ -259,7 +268,7 @@ def on_setpoint_change(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = PositionerBox(device="bpm4i") widget.show() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 236303805..d57c22c6b 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -13,7 +13,7 @@ from qtpy.QtWidgets import QDoubleSpinBox from bec_widgets.utils import UILoader -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( @@ -37,6 +37,8 @@ class PositionerBox2D(PositionerBoxBase): USER_ACCESS = [ "set_positioner_hor", "set_positioner_ver", + "attach", + "detach", "screenshot", "enable_controls_hor", "enable_controls_hor.setter", @@ -71,6 +73,8 @@ def __init__( self._limits_hor = None self._limits_ver = None self._dialog = None + self._hide_device_selection = False + self._hide_device_boxes = False self._enable_controls_hor = True self._enable_controls_ver = True if self.current_path == "": @@ -223,22 +227,24 @@ def device_ver(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button_hor.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button_hor.setVisible(not value) self.ui.tool_button_ver.setVisible(not value) @SafeProperty(bool) def hide_device_boxes(self): """Hide the device selection""" - return not self.ui.device_box_hor.isVisible() + return self._hide_device_boxes @hide_device_boxes.setter def hide_device_boxes(self, value: bool): """Set the device selection visibility""" + self._hide_device_boxes = value self.ui.device_box_hor.setVisible(not value) self.ui.device_box_ver.setVisible(not value) @@ -327,7 +333,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_hor, "units": self.ui.units_hor, } - elif device == "vertical": + if device == "vertical": return { "spinner": self.ui.spinner_widget_ver, "position_indicator": self.ui.position_indicator_ver, @@ -340,8 +346,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_ver, "units": self.ui.units_ver, } - else: - raise ValueError(f"Device {device} is not represented by this UI") + raise ValueError(f"Device {device} is not represented by this UI") def _device_ui_components(self, device: str): if device == self.device_hor: @@ -529,7 +534,7 @@ def on_setpoint_change_ver(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = PositionerBox2D() widget.show() diff --git a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py index f3ac8892a..e16c83718 100644 --- a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +++ b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py @@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget): PLUGIN = True ICON_NAME = "grid_view" - USER_ACCESS = ["set_positioners"] + USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"] # Signal emitted to inform listeners about a position update of the first positioner position_update = Signal(float) diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py index 24b9c7db5..8db1a14a5 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py @@ -32,6 +32,7 @@ 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 @@ -125,11 +126,13 @@ def update_devices_from_filters(self): 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 all_dev if self._check_device_filter(dev)] + devs = [dev for dev in devs 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] @@ -190,6 +193,27 @@ def apply_filter(self, value: bool): 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.""" @@ -379,6 +403,20 @@ def _check_device_filter( """ 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: diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py index 4e07b1902..07e993e3d 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py @@ -6,7 +6,7 @@ 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, LineEditFilterHandler +from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.widget_io import WidgetIO @@ -17,6 +17,8 @@ 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 diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index b80227beb..0f923d0cc 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -1,7 +1,6 @@ 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 @@ -27,12 +26,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() @@ -51,6 +50,7 @@ def __init__( 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,6 +63,7 @@ def __init__( 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. @@ -85,6 +86,10 @@ def __init__( # 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) @@ -147,24 +152,6 @@ def get_current_device(self) -> object: dev_name = self.currentText() return self.get_device_object(dev_name) - def paintEvent(self, event: QPaintEvent) -> None: - """Extend the paint event to set the border color based on the validity of the input. - - Args: - event (PySide6.QtGui.QPaintEvent) : Paint event. - """ - # logger.info(f"Received paint event: {event} in {self.__class__}") - super().paintEvent(event) - - if self._is_valid_input is False and self.isEnabled() is True: - painter = QPainter(self) - pen = QPen() - pen.setWidth(2) - pen.setColor(self._accent_colors.emergency) - painter.setPen(pen) - painter.drawRect(self.rect().adjusted(1, 1, -1, -1)) - painter.end() - @Slot(str) def check_validity(self, input_text: str) -> None: """ @@ -173,10 +160,12 @@ def check_validity(self, input_text: str) -> None: if self.validate_device(input_text) is True: self._is_valid_input = True self.device_selected.emit(input_text) + self.setStyleSheet("border: 1px solid transparent;") else: self._is_valid_input = False self.device_reset.emit() - self.update() + if self.isEnabled(): + self.setStyleSheet("border: 1px solid red;") def validate_device(self, device: str) -> bool: # type: ignore[override] """ @@ -197,21 +186,70 @@ def validate_device(self, device: str) -> bool: # type: ignore[override] 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, QVBoxLayout, QWidget - - from bec_widgets.utils.colors import set_theme + from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + ) + + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) + 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) + combo = DeviceComboBox() - combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"] + combo.set_first_element_as_empty = True 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_() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py index 3a0f1925c..5917b8062 100644 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +++ b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py @@ -31,12 +31,11 @@ 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__( @@ -175,13 +174,13 @@ def check_validity(self, input_text: str) -> None: # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( SignalComboBox, ) app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 7c0bdaddb..892d30ab7 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -1,7 +1,6 @@ from __future__ import annotations -from bec_lib.device import Positioner -from qtpy.QtCore import QSize, Signal +from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import QComboBox, QSizePolicy from bec_widgets.utils.error_popups import SafeProperty, SafeSlot @@ -22,18 +21,27 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): client: BEC client object. config: Device input configuration. gui_id: GUI ID. - device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. + 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. 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"] + USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"] ICON_NAME = "list_alt" PLUGIN = True - RPC = True + RPC = False device_signal_changed = Signal(str) + signal_reset = Signal() def __init__( self, @@ -42,9 +50,13 @@ def __init__( config: DeviceSignalInputBaseConfig | None = None, gui_id: str | None = None, device: str | None = None, - signal_filter: str | list[str] | None = None, + signal_filter: list[Kind] | 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, + store_signal_config: bool = True, + require_device: bool = False, **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) @@ -57,26 +69,64 @@ def __init__( self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.setMinimumSize(QSize(100, 0)) self._set_first_element_as_empty = True - # 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._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. 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""" + """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. + """ 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: @@ -118,6 +168,63 @@ def set_first_element_as_empty(self, value: bool) -> None: 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. @@ -148,6 +255,109 @@ def set_to_first_enabled(self) -> bool: 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.""" @@ -158,36 +368,65 @@ def reset_selection(self): @SafeSlot(str) def on_text_changed(self, text: str): - """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. + """Validate and emit only when the signal is valid. For a positioner, the readback value has to be renamed to the device name. - - Args: - text (str): Text in the combobox. + When using signal_class_filter, device validation is skipped. """ - if self.validate_device(self.device) is False: - return - if self.validate_signal(text) is False: - return - self.device_signal_changed.emit(text) + 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;") @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 from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() widget.setLayout(layout) - box = SignalComboBox(device="samx") + 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) layout.addWidget(box) widget.show() app.exec_() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py index 4c8ecb0d9..a7e9fe1f3 100644 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +++ b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py @@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit): device_signal_changed = Signal(str) PLUGIN = True - RPC = True + RPC = False ICON_NAME = "vital_signs" def __init__( @@ -147,13 +147,13 @@ def on_text_changed(self, text: str): # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( DeviceComboBox, ) app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/device_manager/__init__.py b/bec_widgets/widgets/control/device_manager/__init__.py index e69de29bb..dca7392e7 100644 --- a/bec_widgets/widgets/control/device_manager/__init__.py +++ b/bec_widgets/widgets/control/device_manager/__init__.py @@ -0,0 +1 @@ +from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index e69de29bb..d33639770 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -0,0 +1,5 @@ +# from .device_table_view import DeviceTableView +from .device_table.device_table import DeviceTable +from .dm_config_view import DMConfigView +from .dm_docstring_view import DocstringView, docstring_to_markdown +from .ophyd_validation.ophyd_validation import OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py new file mode 100644 index 000000000..fb1f69935 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -0,0 +1,53 @@ +import json +from typing import Any, Callable, Generator, Iterable, TypeVar + +from bec_lib.utils.json import ExtendedEncoder +from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore +from qtpy.QtWidgets import QListWidgetItem + +from bec_widgets.widgets.control.device_manager.components.constants import ( + MIME_DEVICE_CONFIG, + SORT_KEY_ROLE, +) + +_T = TypeVar("_T") +_RT = TypeVar("_RT") + + +def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]: + for v in vals: + try: + yield fn(v) + except BaseException: + pass + + +def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData: + """Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG""" + mime_obj = QMimeData() + byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj + + +class SortableQListWidgetItem(QListWidgetItem): + """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with + custom widgets and this item.""" + + def __gt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() > other_key.lower() + + def __lt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() < other_key.lower() + + +class SharedSelectionSignal(QObject): + proc = Signal(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py new file mode 100644 index 000000000..83d4d4d0f --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py @@ -0,0 +1,3 @@ +from .available_device_resources import AvailableDeviceResources + +__all__ = ["AvailableDeviceResources"] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py new file mode 100644 index 000000000..96759d7b2 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -0,0 +1,230 @@ +from textwrap import dedent +from typing import NamedTuple +from uuid import uuid4 + +from bec_qthemes import material_icon +from qtpy.QtCore import QItemSelection, QSize, Signal +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import ( + Ui_AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +def _warning_string(spec: HashableDevice): + name_warning = ( + "Device defined with multiple names! Please check:\n " + "\n ".join(spec.names) + if len(spec.names) > 1 + else "" + ) + source_warning = ( + "Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files) + if len(spec._source_files) > 1 + else "" + ) + return f"{name_warning}{source_warning}" + + +class _DeviceEntryWidget(QFrame): + + def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self._device_spec = device_spec + self.included: bool = False + + self.setFrameStyle(0) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(2, 2, 2, 2) + self.setLayout(self._layout) + + self.setup_title_layout(device_spec) + self.check_and_display_warning() + + self.setToolTip(self._rich_text()) + + def _rich_text(self): + return dedent( + f""" +

{self._device_spec.name}:

+ + + + + +
description: {self._device_spec.description}
config: {self._device_spec.deviceConfig}
enabled: {self._device_spec.enabled}
read only: {self._device_spec.readOnly}
+ """ + ) + + def setup_title_layout(self, device_spec: HashableDevice): + self._title_layout = QHBoxLayout() + self._title_layout.setContentsMargins(0, 0, 0, 0) + self._title_container = QWidget(parent=self) + self._title_container.setLayout(self._title_layout) + + self._warning_label = QLabel() + self._title_layout.addWidget(self._warning_label) + + self.title = QLabel(device_spec.name) + self.title.setToolTip(device_spec.name) + self.title.setStyleSheet(self.title_style("#FF0000")) + self._title_layout.addWidget(self.title) + + self._title_layout.addStretch(1) + self._layout.addWidget(self._title_container) + + def check_and_display_warning(self): + if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1: + self._warning_label.setText("") + self._warning_label.setToolTip("") + else: + self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00")) + self._warning_label.setToolTip(_warning_string(self._device_spec)) + + @property + def device_hash(self): + return hash(self._device_spec) + + def title_style(self, color: str) -> str: + return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}" + + def setTitle(self, text: str): + self.title.setText(text) + + def set_included(self, included: bool): + self.included = included + self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000")) + + +class _DeviceEntry(NamedTuple): + list_item: QListWidgetItem + widget: _DeviceEntryWidget + + +class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): + + selected_devices = Signal(list) + + def __init__( + self, + parent=None, + name: str = "TagGroupTitle", + data: set[HashableDevice] = set(), + shared_selection_signal=SharedSelectionSignal(), + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self.title_text = name # type: ignore + self._mime_data = [] + self._devices: dict[str, _DeviceEntry] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + self.setMinimumSize(self.device_list.sizeHint()) + self._update_num_included() + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + device_dump = device.model_dump(exclude_defaults=True) + item.setData(CONFIG_DATA_ROLE, device_dump) + self._mime_data.append(device_dump) + widget = _DeviceEntryWidget(device, self) + item.setSizeHint(QSize(widget.width(), widget.height())) + self.device_list.setItemWidget(item, widget) + self.device_list.addItem(item) + self._devices[device.name] = _DeviceEntry(item, widget) + + def create_mime_data(self): + return self._mime_data + + def reset_devices_state(self): + for dev in self._devices.values(): + dev.widget.set_included(False) + self._update_num_included() + + def set_item_state(self, /, device_hash: int, included: bool): + for dev in self._devices.values(): + if dev.widget.device_hash == device_hash: + dev.widget.set_included(included) + self._update_num_included() + + def _update_num_included(self): + n_included = sum(int(dev.widget.included) for dev in self._devices.values()) + if n_included == 0: + color = "#FF0000" + elif n_included == len(self._devices): + color = "#00FF00" + else: + color = "#FFAA00" + self.n_included.setText(f"{n_included} / {len(self._devices)}") + self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}") + + def sizeHint(self) -> QSize: + if not getattr(self, "device_list", None) or not self.expanded: + return super().sizeHint() + return QSize( + max(150, self.device_list.viewport().width()), + self.device_list.sizeHintForRow(0) * self.device_list.count() + 50, + ) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + config = [dev.as_normal_device().model_dump() for dev in self.get_selection()] + self.selected_devices.emit(config) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_list.clearSelection() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self.sizeHint().height()) + self.setMaximumHeight(self.sizeHint().height()) + + def get_selection(self) -> set[HashableDevice]: + selection = self.device_list.selectedItems() + widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection) + return set(w._device_spec for w in widgets) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.title_text}" + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceGroup(name="Tag group 1") + for item in [ + HashableDevice( + **{ + "name": f"test_device_{i}", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, + } + ) + for i in range(5) + ]: + widget._add_item(item) + widget._update_num_included() + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py new file mode 100644 index 000000000..bea0a1c34 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout + +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + +if TYPE_CHECKING: + from .available_device_group import AvailableDeviceGroup + + +class _DeviceListWiget(QListWidget): + + def _item_iter(self): + return (self.item(i) for i in range(self.count())) + + def all_configs(self): + return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()] + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items) + + +class Ui_AvailableDeviceGroup(object): + def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"): + if not AvailableDeviceGroup.objectName(): + AvailableDeviceGroup.setObjectName("AvailableDeviceGroup") + AvailableDeviceGroup.setMinimumWidth(150) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + AvailableDeviceGroup.set_layout(self.verticalLayout) + + title_layout = AvailableDeviceGroup.get_title_layout() + + self.n_included = QLabel(AvailableDeviceGroup, text="...") + self.n_included.setObjectName("n_included") + title_layout.addWidget(self.n_included) + + self.device_list = _DeviceListWiget(AvailableDeviceGroup) + self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_list.setObjectName("device_list") + self.device_list.setFrameStyle(0) + self.device_list.setDragEnabled(True) + self.device_list.setAcceptDrops(False) + self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.verticalLayout.addWidget(self.device_list) + AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box) + QMetaObject.connectSlotsByName(AvailableDeviceGroup) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 000000000..93e810156 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,128 @@ +from random import randint +from typing import Any, Iterable +from uuid import uuid4 + +from qtpy.QtCore import QItemSelection, Signal # type: ignore +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import ( + SharedSelectionSignal, + yield_only_passing, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( + Ui_availableDeviceResources, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + get_backend, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + + selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected + add_selected_devices = Signal(list) + del_selected_devices = Signal(list) + + def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self._backend = get_backend() + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_groups_list.selectionModel().selectionChanged.connect( + self._on_selection_changed + ) + self.grouping_selector.addItem("deviceTags") + self.grouping_selector.addItems(self._backend.allowed_sort_keys) + self._grouping_selection_changed("deviceTags") + self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed) + self.search_box.textChanged.connect(self.device_groups_list.update_filter) + + self.tb_add_selected.action.triggered.connect(self._add_selected_action) + self.tb_del_selected.action.triggered.connect(self._del_selected_action) + + def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): + self.device_groups_list.clear() + for device_group, devices in device_groups.items(): + self._add_device_group(device_group, devices) + if self.grouping_selector.currentText == "deviceTags": + self._add_device_group("Untagged devices", self._backend.untagged_devices) + self.device_groups_list.sortItems() + + def _add_device_group(self, device_group: str, devices: set[HashableDevice]): + item, widget = self.device_groups_list.add_item( + device_group, + self.device_groups_list, + device_group, + devices, + shared_selection_signal=self._shared_selection_signal, + expanded=False, + ) + item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) + # Re-emit the selected items from a subgroup - all other selections should be disabled anyway + widget.selected_devices.connect(self.selected_devices) + + def resizeEvent(self, event): + super().resizeEvent(event) + for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): + list_item.setSizeHint(device_group_widget.sizeHint()) + + @SafeSlot() + def _add_selected_action(self): + self.add_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot() + def _del_selected_action(self): + self.del_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups()) + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_groups_list.clearSelection() + + def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for device in devices: + for device_group in self.device_groups_list.widgets(): + device_group.set_item_state(hash(device), included) + + @SafeSlot(list) + def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool): + """Set the display color of individual devices and update the group display of numbers + included. Accepts a list of dicts with the complete config as used in + bec_lib.atlas_models.Device.""" + self._set_devices_state( + yield_only_passing(HashableDevice.model_validate, config_list), used + ) + + @SafeSlot(str) + def _grouping_selection_changed(self, sort_key: str): + self.search_box.setText("") + if sort_key == "deviceTags": + device_groups = self._backend.tag_groups + else: + device_groups = self._backend.group_by_key(sort_key) + self.refresh_full_list(device_groups) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceResources() + widget._set_devices_state( + list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True + ) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py new file mode 100644 index 000000000..05701864a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import itertools + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QGridLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, +) + +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +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.control.device_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( + AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup: + return super().itemWidget(item) # type: ignore + + def any_selected_devices(self): + return self.selected_individual_devices() or self.selected_devices_from_groups() + + def selected_individual_devices(self): + for widget in (self.itemWidget(self.item(i)) for i in range(self.count())): + if (selected := widget.get_selection()) != set(): + return [dev.as_normal_device().model_dump() for dev in selected] + return [] + + def selected_devices_from_groups(self): + selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows()) + widgets = (self.itemWidget(item) for item in selected_items) + return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets)) + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs( + itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items) + ) + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + + self._add_toolbar() + + # Main area with search and filter using a grid layout + self.search_layout = QVBoxLayout() + self.grid_layout = QGridLayout() + + self.grouping_selector = QComboBox() + self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_group = QLabel("Group by:") + lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_group, 0, 0) + self.grid_layout.addWidget(self.grouping_selector, 0, 1) + + self.search_box = QLineEdit() + self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_filter = QLabel("Filter:") + lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_filter, 1, 0) + self.grid_layout.addWidget(self.search_box, 1, 1) + + self.grid_layout.setColumnStretch(0, 0) + self.grid_layout.setColumnStretch(1, 1) + + self.search_layout.addLayout(self.grid_layout) + self.verticalLayout.addLayout(self.search_layout) + + self.device_groups_list = _ListOfDeviceGroups( + availableDeviceResources, AvailableDeviceGroup + ) + self.device_groups_list.setObjectName("device_groups_list") + self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setMovement(QListView.Movement.Static) + self.device_groups_list.setSpacing(4) + self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) + self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) + self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_groups_list.setDragEnabled(True) + self.device_groups_list.setAcceptDrops(False) + self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + availableDeviceResources.setMinimumWidth(250) + availableDeviceResources.resize(250, availableDeviceResources.height()) + + self.verticalLayout.addWidget(self.device_groups_list) + + QMetaObject.connectSlotsByName(availableDeviceResources) + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + self.tb_add_selected = MaterialIconAction( + icon_name="add_box", parent=self, tooltip="Add selected devices to composition" + ) + self.toolbar.components.add_safe("add_selected", self.tb_add_selected) + io_bundle.add_action("add_selected") + + self.tb_del_selected = MaterialIconAction( + icon_name="chips", parent=self, tooltip="Remove selected devices from composition" + ) + self.toolbar.components.add_safe("del_selected", self.tb_del_selected) + io_bundle.add_action("del_selected") + + self.verticalLayout.addWidget(self.toolbar) + self.toolbar.add_bundle(io_bundle) + self.toolbar.show_bundles(["IO"]) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py new file mode 100644 index 000000000..145d21109 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import operator +import os +from enum import Enum, auto +from functools import partial, reduce +from glob import glob +from pathlib import Path +from typing import Protocol + +import bec_lib +from bec_lib.atlas_models import HashableDevice, HashableDeviceSet +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed + +logger = bec_logger.logger + +# use the last n recovery files +_N_RECOVERY_FILES = 3 +_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.." + + +def get_backend() -> DeviceResourceBackend: + return _ConfigFileBackend() + + +class HashModel(str, Enum): + DEFAULT = auto() + DEFAULT_DEVICECONFIG = auto() + DEFAULT_EPICS = auto() + + +class DeviceResourceBackend(Protocol): + @property + def tag_groups(self) -> dict[str, set[HashableDevice]]: + """A dictionary of all availble devices separated by tag groups. The same device may + appear more than once (in different groups).""" + ... + + @property + def all_devices(self) -> set[HashableDevice]: + """A set of all availble devices. The same device may not appear more than once.""" + ... + + @property + def untagged_devices(self) -> set[HashableDevice]: + """A set of all untagged devices. The same device may not appear more than once.""" + ... + + @property + def allowed_sort_keys(self) -> set[str]: + """A set of all fields which you may group devices by""" + ... + + def tags(self) -> set[str]: + """Returns a set of all the tags in all available devices.""" + ... + + def tag_group(self, tag: str) -> set[HashableDevice]: + """Returns a set of the devices in the tag group with the given key.""" + ... + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + """Return a dict of all devices, organised by the specified key, which must be one of + the string keys in the Device model.""" + ... + + +def _devices_from_file(file: str, include_source: bool = True): + data = yaml_load(file, process_includes=False) + return HashableDeviceSet( + HashableDevice.model_validate( + dev | {"name": name, "source_files": {file} if include_source else set()} + ) + for name, dev in data.items() + ) + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files() + if plugins_installed() == 1: + self._raw_device_set.update( + self._get_configs_from_plugin_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + ) + self._device_groups = self._get_tag_groups() + + def _get_config_from_backup_files(self): + dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" + files = sorted(glob("*.yaml", root_dir=dir)) + last_n_files = files[-_N_RECOVERY_FILES:] + return reduce( + operator.or_, + map( + partial(_devices_from_file, include_source=False), + (str(dir / f) for f in last_n_files), + ), + set(), + ) + + def _get_configs_from_plugin_files(self, dir: Path): + files = glob("*.yaml", root_dir=dir, recursive=True) + return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set()) + + def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: + return { + tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set)) + for tag in self.tags() + } + + @property + def tag_groups(self): + return self._device_groups + + @property + def all_devices(self): + return self._raw_device_set + + @property + def untagged_devices(self): + return {d for d in self._raw_device_set if d.deviceTags == set()} + + @property + def allowed_sort_keys(self) -> set[str]: + return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} + + def tags(self) -> set[str]: + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set()) + + def tag_group(self, tag: str) -> set[HashableDevice]: + return self.tag_groups[tag] + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + if key not in self.allowed_sort_keys: + raise ValueError(f"Cannot group available devices by model key {key}") + group_names: set[str] = {getattr(item, key) for item in self._raw_device_set} + return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names} diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py new file mode 100644 index 000000000..8ac82f0b4 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -0,0 +1,113 @@ +from typing import Final + +# Denotes a MIME type for JSON-encoded list of device config dictionaries +MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config" + +# Custom user roles +SORT_KEY_ROLE: Final[int] = 117 +CONFIG_DATA_ROLE: Final[int] = 118 + +# TODO 882 keep in sync with headers in device_table_view.py +HEADERS_HELP_MD: dict[str, str] = { + "valid": { + "long": "\n".join( + [ + "## Valid", + "The current configuration status of the device. Can be one of the following values: ", + "### **VALID** \n The device configuration is valid and can be used.", + "### **INVALID** \n The device configuration is invalid.", + "### **UNKNOWN** \n The device configuration has not been validated yet.", + ] + ), + "short": "Validation status of the device configuration.", + }, + "connect": { + "long": "\n".join( + [ + "## Connect", + "The current connection status of the device. Can be one of the following values: ", + "### **CONNECTED** \n The device is connected and in current session.", + "### **CAN_CONNECT** \n The connection to the device has been validated. It's not yet loaded in the current session.", + "### **CANNOT_CONNECT** \n The connection to the device could not be established.", + "### **UNKNOWN** \n The connection status of the device is unknown.", + ] + ), + "short": "Connection status of the device.", + }, + "name": { + "long": "\n".join(["## Name ", "The name of the device."]), + "short": "Name of the device.", + }, + "deviceClass": { + "long": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "short": "Python class for the device.", + }, + "readoutPriority": { + "long": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "short": "Readout priority of the device for scans in BEC.", + }, + "deviceTags": { + "long": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "short": "Tags associated with the device.", + }, + "enabled": { + "long": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "short": "Enabled status of the device.", + }, + "readOnly": { + "long": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "short": "Read-only status of the device.", + }, + "onFailure": { + "long": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "short": "On failure behavior of the device.", + }, + "softwareTrigger": { + "long": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "short": "Software trigger status of the device.", + }, + "description": { + "long": "\n".join(["## Description", "A short description of the device."]), + "short": "Description of the device.", + }, +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py new file mode 100644 index 000000000..508cef68b --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py @@ -0,0 +1,519 @@ +"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV""" + +from copy import deepcopy +from typing import Any, Type + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from pydantic import BaseModel +from pydantic_core import PydanticUndefinedType +from qtpy import QtWidgets + +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +class DeviceConfigTemplate(QtWidgets.QWidget): + """ + Device Configuration Template Widget. + Current supported templates follow the structure in + ophyd_devices.interfaces.device_config_templates.ophyd_templates.OPHYD_DEVICE_TEMPLATES. + + Args: + parent (QtWidgets.QWidget, optional) : Parent widget. Defaults to None. + client (BECClient, optional) : BECClient instance. Defaults to None. + template (dict[str, any], optional) : Device configuration template. If None, + the "CustomDevice" template will be used. Defaults to None. + """ + + RPC = False + + def __init__(self, parent=None, template: dict[str, any] = None): + super().__init__(parent=parent) + if template is None: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + self.template = template + self._device_fields = deepcopy(DEVICE_FIELDS) + self._device_config_fields = deepcopy(DEVICE_CONFIG_FIELDS) + self._unknown_device_config_entry: dict[str, any] = {} + + # Dict to store references to input widgets + self._widgets: dict[str, QtWidgets.QWidget] = {} + + # Two column layout + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 0) + layout.setSpacing(2) + self.setLayout(layout) + + # Left hand side, settings, connection and advanced settings + self._left_layout = QtWidgets.QVBoxLayout() + self._left_layout.setContentsMargins(2, 2, 2, 2) + self._left_layout.setSpacing(4) + # Settings box, name | deviceClass | description + self.settings_box = self._create_settings_box() + # Device Config settings box | dynamic fields from deviceConfig + self.connection_settings_box = self._create_connection_settings_box() + # Advanced Control box | readoutPriority | onFailure | softwareTrigger | enabled | readOnly + self.advanced_control_box = self._create_advanced_control_box() + # Add boxes to left layout + self._left_layout.addWidget(self.settings_box) + self._left_layout.addWidget(self.connection_settings_box) + self._left_layout.addWidget(self.advanced_control_box) + layout.addLayout(self._left_layout) + + # Right hand side, advanced settings + self._right_layout = QtWidgets.QVBoxLayout() + self._right_layout.setContentsMargins(2, 2, 2, 2) + self._right_layout.setSpacing(4) + layout.addLayout(self._right_layout) + # Create Additional Settings box + self.additional_settings_box = self.create_additional_settings() + self._right_layout.addWidget(self.additional_settings_box) + + # Set default values + self.reset_to_defaults() + + def _clear_layout(self, layout: QtWidgets.QLayout) -> None: + """Clear a layout recursively. If the layout contains sub-layouts, they will also be cleared.""" + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().close() + item.widget().deleteLater() + if item.layout(): + self._clear_layout(item.layout()) + + def reset_to_defaults(self) -> None: + """Reset all fields to default values.""" + self._widgets.pop("deviceConfig", None) + self._clear_layout(self.connection_settings_box.layout()) + + # Recreate Connection Settings box + layout: QtWidgets.QGridLayout = self.connection_settings_box.layout() + self._fill_connection_settings_box(self.connection_settings_box, layout) + + # Reset Settings and Advanced Control boxes + for field_name, widget in self._widgets.items(): + if field_name in self.template: + self._set_value_for_widget(widget, self.template[field_name]) + else: + self._set_default_entry(field_name, widget) + + def change_template(self, template: dict[str, any]) -> None: + """ + Change the template and update the form fields accordingly. + + Args: + template (dict[str, any]): New device configuration template. + """ + self.template = template + self.reset_to_defaults() + + def get_config_fields(self) -> dict: + """Retrieve the current configuration from the input fields.""" + config: dict[str, any] = {} + for device_entry, widget in self._widgets.items(): + config[device_entry] = self._get_entry_for_widget(widget) + if self._unknown_device_config_entry: + if "deviceConfig" not in config: + config["deviceConfig"] = {} + config["deviceConfig"].update(self._unknown_device_config_entry) + return config + + def set_config_fields(self, config: dict) -> None: + """ + Set the configuration fields based on the provided config dictionary. + + Args: + config (dict): Configuration dictionary to set the fields. + """ + # Clear storage for unknown entries + self._unknown_device_config_entry.clear() + if self.template.get("deviceClass", "") != config.get("deviceClass", ""): + logger.warning( + f"Device class {config.get('deviceClass', '')} does not match template device class {self.template.get('deviceClass', '')}. Using custom device template." + ) + self.change_template(OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]) + else: + self.reset_to_defaults() + self._fill_fields_from_config(config) + + def _fill_fields_from_config(self, model: dict) -> None: + """ + Fill the form fields base on the provided configuration dictionary. + Please note, deviceConfig is handled separately through _fill_connection_settings_box + as this depends on the template used. + + Args: + model (dict): Configuration dictionary to fill the fields. + """ + for key, value in model.items(): + if key == "name": + wid = self._widgets["name"] + wid.setText(value or "") + elif key == "deviceClass": + wid = self._widgets["deviceClass"] + wid.setText(value or "") + if "deviceClass" in self.template: + wid.setEnabled(False) + else: + wid.setEnabled(True) + elif key == "deviceConfig" and isinstance( + self._widgets.get("deviceConfig", None), dict + ): + # If _widgets["deviceConfig"] is a dict, we have individual widgets for each field + for sub_key, sub_value in value.items(): + widget = self._widgets["deviceConfig"].get(sub_key, None) + if widget is None: + logger.warning( + f"Widget for key {sub_key} not found in deviceConfig widgets." + ) + # Store any unknown entry fields + self._unknown_device_config_entry[sub_key] = sub_value + continue + self._set_value_for_widget(widget, sub_value) + else: + widget = self._widgets.get(key, None) + if widget is not None: + self._set_value_for_widget(widget, value) + + def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: Any) -> None: + """ + Set the value for a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to set the value for. + value (any): The value to set. + """ + if isinstance(widget, (ParameterValueWidget)) and isinstance(value, dict): + for param, val in value.items(): + widget.add_parameter_line(param, val) + elif isinstance(widget, DeviceTagsWidget) and isinstance(value, (list, tuple, set)): + for tag in value: + widget.add_parameter_line(tag or "") + elif isinstance(widget, InputLineEdit): + widget.setText(str(value or "")) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(value)) + elif isinstance(widget, LimitInputWidget): + widget.set_limits(value) + elif isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(value) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, QtWidgets.QTextEdit): + widget.setPlainText(str(value or "")) + else: + logger.warning(f"Unsupported widget type for setting value: {type(widget)}") + + def _get_entry_for_widget(self, widget: QtWidgets.QWidget) -> any: + """ + Get the value from a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to get the value from. + Returns: + any: The value retrieved from the widget. + """ + if isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + return widget.parameters() + elif isinstance(widget, InputLineEdit): + return widget.text().strip() + elif isinstance(widget, ToggleSwitch): + return widget.isChecked() + elif isinstance(widget, LimitInputWidget): + return widget.get_limits() + elif isinstance(widget, QtWidgets.QComboBox): + return widget.currentText() + elif isinstance(widget, QtWidgets.QTextEdit): + return widget.toPlainText() + elif isinstance(widget, dict): + result = {} + for sub_entry, sub_widget in widget.items(): + result[sub_entry] = self._get_entry_for_widget(sub_widget) + return result + else: + logger.warning(f"Unsupported widget type for getting entry: {type(widget)}") + return None + + def _create_device_field( + self, field_name: str, field_info: DeviceConfigField | None = None + ) -> tuple[QtWidgets.QLabel, QtWidgets.QWidget]: + """ + Create a device field based on the field name. If field_info is not provided, + a default label and input widget will be created. + + Args: + field_name (str): Name of the field. + field_info (DeviceConfigField | None, optional): Information about the field. Defaults to None. + """ + if field_info is None: + label = QtWidgets.QLabel(field_name, parent=self) + input_widget = QtWidgets.QLineEdit(parent=self) + return label, input_widget + + label_text = field_info.label + label = QtWidgets.QLabel(label_text, parent=self) + if field_info.required: + label_text = label.text() + label_text += " *" + label.setText(label_text) + label.setStyleSheet("font-weight: bold;") + input_widget = field_info.widget_cls(parent=self) + if field_info.placeholder_text: + if hasattr(input_widget, "setPlaceholderText"): + input_widget.setPlaceholderText(field_info.placeholder_text) + if field_info.static: + input_widget.setEnabled(False) + if field_info.validation_callback: + # Attach validation callback if provided + if isinstance(input_widget, InputLineEdit): + input_widget: InputLineEdit + for callback in field_info.validation_callback: + input_widget.register_validation_callback(callback) + if field_info.default is not None: + # Set default value + if isinstance(input_widget, QtWidgets.QLineEdit): + input_widget.setText(str(field_info.default)) + elif isinstance(input_widget, QtWidgets.QTextEdit): + input_widget.setPlainText(str(field_info.default)) + elif isinstance(input_widget, ToggleSwitch): + input_widget.setChecked(bool(field_info.default)) + elif isinstance(input_widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + index = input_widget.findText(field_info.default) + if index != -1: + input_widget.setCurrentIndex(index) + return label, input_widget + + def _create_group_box_with_grid_layout( + self, title: str + ) -> tuple[QtWidgets.QGroupBox, QtWidgets.QGridLayout]: + """Create a group box with a grid layout.""" + box = QtWidgets.QGroupBox(title) + layout = QtWidgets.QGridLayout(box) + layout.setContentsMargins(4, 8, 4, 8) + layout.setSpacing(4) + box.setLayout(layout) + return box, layout + + def _set_default_entry(self, field_name: str, widget: QtWidgets.QWidget) -> None: + """ + Set the default value for a given field in the form based on the Pydantic model. + + Args: + field_name (str): Name of the field. + widget (QtWidgets.QWidget): The widget to set the default value for. + """ + if field_name == "enabled": + widget.setChecked(True) + return + if field_name == "readOnly": + widget.setChecked(False) + return + default = self._get_default_for_device_config_field(field_name) or "" + widget.setEnabled(True) + if isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(default) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit)): + widget.setText(str(default)) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(default)) + elif isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + widget.clear_widget() + + def _get_default_for_device_config_field(self, field_name: str) -> any: + """ + Get the default value for a given deviceConfig field based on the Pydantic model. + + Args: + field_name (str): Name of the deviceConfig field. + Returns: + any: The default value for the field, or None if not found. + """ + model_properties: dict = DeviceModel.model_json_schema()["properties"] + if field_name in model_properties: + field_info = model_properties[field_name] + default = field_info.get("default", None) + if default: + return default + return None + + ### Box creation methods ### + + def _create_box(self, box_title: str, field_names: list[str]) -> QtWidgets.QGroupBox: + """ + Create a box layout with specific fields. If field_names are in _device_fields, + their corresponding widgets will be used. + """ + # Create box + box, layout = self._create_group_box_with_grid_layout(box_title) + box.setLayout(layout) + + for ii, field_name in enumerate(field_names): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 0) + layout.addWidget(input_widget, ii, 1) + self._widgets[field_name] = input_widget + return box + + def _create_settings_box(self) -> QtWidgets.QGroupBox: + """Create the settings box widget.""" + box = self._create_box("Settings", ["name", "deviceClass", "description"]) + layout = box.layout() + # Set column stretch + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 1) + return box + + def _create_advanced_control_box(self) -> QtWidgets.QGroupBox: + """Create the advanced control box widget.""" + # Set up advanced control box + box = self._create_box("Advanced Control", ["readoutPriority", "onFailure"]) + layout = box.layout() + for ii, field_name in enumerate(["enabled", "readOnly", "softwareTrigger"]): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 2) + layout.addWidget(input_widget, ii, 3) + self._widgets[field_name] = input_widget + return box + + def _create_connection_settings_box(self) -> QtWidgets.QGroupBox: + """Create the connection settings box widget. These are all entries in the deviceConfig field.""" + box, layout = self._create_group_box_with_grid_layout("Connection Settings") + box = self._fill_connection_settings_box(box, layout) + return box + + def _fill_connection_settings_box( + self, box: QtWidgets.QGroupBox, layout: QtWidgets.QGridLayout + ) -> QtWidgets.QGroupBox: + """Fill the connection settings box based on the deviceConfig template.""" + if not self.template.get("deviceConfig", {}): + widget = ParameterValueWidget(parent=self) + widget.setToolTip( + "Add custom deviceConfig entries as key-value pairs in the tree view." + ) + layout.addWidget(widget, 0, 0) + self._widgets["deviceConfig"] = widget + return box + # If template specifies deviceConfig fields, create them + self._widgets["deviceConfig"] = {} + model: Type[BaseModel] = self.template["deviceConfig"] + for field_name, field in model.model_fields.items(): + field_info = self._device_config_fields.get(field_name, None) + default = field.get_default() + if isinstance(default, PydanticUndefinedType): + default = None + if field_info: + if field.is_required(): + field_info.required = True + if field.description: + field_info.placeholder_text = field.description + if default is not None: + field_info.default = default + label, input_widget = self._create_device_field(field_name, field_info) + row = layout.rowCount() + layout.addWidget(label, row, 0) + layout.addWidget(input_widget, row, 1) + self._widgets["deviceConfig"][field_name] = input_widget + return box + + def create_additional_settings(self) -> QtWidgets.QGroupBox: + """Create the additional settings box widget.""" + box, layout = self._create_group_box_with_grid_layout("Additional Settings") + toolbox = QtWidgets.QToolBox(parent=self) + layout.addWidget(toolbox, 0, 0) + user_parameters_widget = ParameterValueWidget(parent=self) + self._widgets["userParameter"] = user_parameters_widget + toolbox.addItem(user_parameters_widget, "User Parameter") + device_tags_widget = DeviceTagsWidget(parent=self) + toolbox.addItem(device_tags_widget, "Device Tags") + toolbox.setCurrentIndex(1) + self._widgets["deviceTags"] = device_tags_widget + return box + + +if __name__ == """__main__""": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import yaml + from bec_qthemes import apply_theme + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + apply_theme("light") + + class TestWidget(QtWidgets.QWidget): + pass + + w = TestWidget() + w_layout = QtWidgets.QVBoxLayout(w) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(20) + dark_mode_button = DarkModeButton() + w_layout.addWidget(dark_mode_button) + test_motor = "EpicsMotor" + config_form = DeviceConfigTemplate(template=OPHYD_DEVICE_TEMPLATES[test_motor][test_motor]) + w_layout.addWidget(config_form) + button_layout = QtWidgets.QHBoxLayout() + button = QtWidgets.QPushButton("Get Config") + button.clicked.connect( + lambda: print("Device Config:", yaml.dump(config_form.get_config_fields(), indent=4)) + ) + button_layout.addWidget(button) + button2 = QtWidgets.QPushButton("Reset") + button2.clicked.connect(config_form.reset_to_defaults) + button_layout.addWidget(button2) + combo = QtWidgets.QComboBox() + combo_keys = [ + "EpicsMotor", + "EpicsSignal", + "EpicsSignalRO", + "EpicsSignalWithRBV", + "CustomDevice", + ] + combo.addItems(combo_keys) + combo.setCurrentText(test_motor) + + def text_changed(text: str) -> None: + if text.startswith("EpicsMotor"): + if text == "EpicsMotor": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsMotor"][text] + elif text.startswith("EpicsSignal"): + if text == "EpicsSignal": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsSignal"][text] + else: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + config_form.change_template(template) + + combo.currentTextChanged.connect(text_changed) + button_layout.addWidget(button) + button_layout.addWidget(combo) + w_layout.addLayout(button_layout) + w.resize(1200, 600) + w.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py new file mode 100644 index 000000000..9dc661589 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py @@ -0,0 +1,481 @@ +"""Module for custom input widgets used in device configuration templates.""" + +from ast import literal_eval +from typing import Any, Callable + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from pydantic import BaseModel, ConfigDict +from qtpy import QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.scan_control.scan_group_box import ScanDoubleSpinBox +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +def _try_literal_eval(value: str) -> Any: + """Consolidated function for literal evaluation of a value.""" + if value in ["true", "True"]: + return True + if value in ["false", "False"]: + return False + if value == "": + return "" + try: + return literal_eval(f"{value}") + except ValueError: + return value + except Exception: + logger.warning(f"Could not literal_eval value: {value}, returning as string") + return value + + +class InputLineEdit(QtWidgets.QLineEdit): + """ + Custom QLineEdit for input fields with validation. + + Args: + parent (QtWidgets.QWidget, optional): Parent widget. Defaults to None. + config_field (str, optional): Configuration field name. Defaults to "no_field_specified" + required (bool, optional): Whether the field is required. Defaults to True. + placeholder_text (str, optional): Placeholder text for the input field. Defaults to "". + """ + + def __init__( + self, + parent=None, + config_field: str = "no_field_specified", + required: bool = True, + placeholder_text: str = "", + ): + super().__init__(parent) + self._config_field = config_field + self._colors = get_accent_colors() + self._required = required + self.textChanged.connect(self._update_input_field_style) + self._validation_callbacks: list[Callable[[bool], str]] = [] + self.setPlaceholderText(placeholder_text) + self._update_input_field_style() + + def register_validation_callback(self, callback: Callable[[str], bool]) -> None: + """ + Register a custom validation callback. + + Args: + callback (Callable[[str], bool]): A function that takes the input string + and returns True if valid, False otherwise. + """ + self._validation_callbacks.append(callback) + + def apply_theme(self, theme: str) -> None: + """Apply the theme to the widget.""" + self._colors = get_accent_colors() + self._update_input_field_style() + + def _update_input_field_style(self) -> None: + """Update the input field style based on validation.""" + name = self.text() + if not self.is_valid_input(name) and self._required is True: + self.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + return + self.setStyleSheet("") + return + + def is_valid_input(self, name: str) -> bool: + """Validate the input string using plugin helper.""" + name = name.strip() # Remove leading/trailing whitespace + # Run registered validation callbacks + for callback in self._validation_callbacks: + try: + valid = callback(name) + except Exception as exc: + logger.warning( + f"Validation callback raised an exception: {exc}. Defaulting to valid" + ) + valid = True + if not valid: + return False + if not self._required: + return True + if not name: + return False + return True + + +class OnFailureComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the onFailure input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["buffer", "retry", "raise"]) + + +class ReadoutPriorityComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the readoutPriority input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["monitored", "baseline", "async", "continuous", "on_request"]) + + +class LimitInputWidget(QtWidgets.QWidget): + """Custom widget for inputting limits as a tuple (min, max).""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self._layout = QtWidgets.QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + + # Colors + self._colors = get_accent_colors() + + self.min_input = ScanDoubleSpinBox(self, arg_name="min_limit", default=0.0) + self.min_input.setPrefix("Min: ") + self.min_input.setEnabled(False) + self.min_input.setRange(-1e12, 1e12) + self._layout.addWidget(self.min_input) + + self.max_input = ScanDoubleSpinBox(self, arg_name="max_limit", default=0.0) + self.max_input.setPrefix("Max: ") + self.max_input.setRange(-1e12, 1e12) + self.max_input.setEnabled(False) + self._layout.addWidget(self.max_input) + + # Add validity checks + self.min_input.valueChanged.connect(self._check_valid_inputs) + self.max_input.valueChanged.connect(self._check_valid_inputs) + + # Add checkbox to enable/disable limits + self.enable_toggle = ToggleSwitch(self) + self.enable_toggle.setToolTip("Enable editing limits") + self.enable_toggle.setChecked(False) + self.enable_toggle.enabled.connect(self._toggle_limits_enabled) + self._layout.addWidget(self.enable_toggle) + + def reset_defaults(self) -> None: + """Reset limits to default values.""" + self.min_input.setValue(0.0) + self.max_input.setValue(0.0) + self.enable_toggle.setChecked(False) + + def _is_valid_limit(self) -> bool: + """Check if the current limits are valid (min < max).""" + return self.min_input.value() <= self.max_input.value() + + def _check_valid_inputs(self) -> None: + """Check if the current inputs are valid and update styles accordingly.""" + if not self._is_valid_limit(): + self.min_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + self.max_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + else: + self.min_input.setStyleSheet("") + self.max_input.setStyleSheet("") + + def _toggle_limits_enabled(self, enable: bool) -> None: + """Enable or disable the limit inputs based on the checkbox state.""" + self.min_input.setEnabled(enable) + self.max_input.setEnabled(enable) + + def get_limits(self) -> list[float, float]: + """Return the limits as a list [min, max].""" + min_val = self.min_input.value() + max_val = self.max_input.value() + return [min_val, max_val] + + def set_limits(self, limits: tuple) -> None: + """Set the limits from a tuple (min, max).""" + checked_state = self.enable_toggle.isChecked() + if not checked_state: + self.enable_toggle.setChecked(True) + self.min_input.setValue(limits[0]) + self.max_input.setValue(limits[1]) + self.enable_toggle.setChecked(checked_state) + + +class ParameterValueWidget(QtWidgets.QWidget): + """Custom QTreeWidget for user parameters input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(2) + self.tree_widget.setHeaderLabels(["Parameter", "Value"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + header = self.tree_widget.header() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None, value: str | None = None) -> None: + """Add a new row with editable Parameter/Value QLineEdits.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Parameter field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Parameter") + self.tree_widget.setItemWidget(item, 0, param_edit) + + # Value field + value_edit = QtWidgets.QLineEdit(self.tree_widget) + value_edit.setPlaceholderText("Value") + self.tree_widget.setItemWidget(item, 1, value_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + if value is not None: + value_edit.setText(str(value)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> dict: + """Return all parameters as a dictionary {parameter: value}.""" + result = {} + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + value_edit = self.tree_widget.itemWidget(item, 1) + if param_edit and value_edit: + key = param_edit.text().strip() + val = value_edit.text().strip() + if key and val: + result[key] = _try_literal_eval(val) + return result + + +class DeviceTagsWidget(QtWidgets.QWidget): + """Custom QTreeWidget for deviceTags input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(1) + self.tree_widget.setHeaderLabels(["Tags"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None) -> None: + """Add a new row with editable Tag QLineEdit.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Tag field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Tag") + self.tree_widget.setItemWidget(item, 0, param_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> list[str]: + """Return all parameters as a list of tags.""" + result = [] + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + if param_edit: + tag = param_edit.text().strip() + if tag: + result.append(tag) + return result + + +# Validation callback for name field +def validate_name(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").isalnum(): + return False + return True + + +# Validation callback for deviceClass field +def validate_device_cls(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").replace(".", "").isalnum(): + return False + return True + + +def validate_prefix(value: str) -> bool: + """Check that the prefix does not contain spaces.""" + if " " in value: + return False + if not value.replace("_", "").replace(".", "").replace("-", "").replace(":", "").isalnum(): + return False + return True + + +class DeviceConfigField(BaseModel): + """Pydantic model for device configuration fields.""" + + label: str + widget_cls: type[QtWidgets.QWidget] + required: bool = False + static: bool = False + placeholder_text: str | None = None + validation_callback: list[Callable[[str], bool]] | None = None + default: Any = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +DEVICE_FIELDS = { + "name": DeviceConfigField( + label="Name", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device name (no spaces or special characters)", + validation_callback=[validate_name], + ), + "deviceClass": DeviceConfigField( + label="Device Class", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device class (no spaces or special characters)", + validation_callback=[validate_device_cls], + ), + "description": DeviceConfigField( + label="Description", + widget_cls=QtWidgets.QTextEdit, + required=False, + placeholder_text="Short device description", + ), + "enabled": DeviceConfigField( + label="Enabled", widget_cls=ToggleSwitch, required=False, default=True + ), + "readOnly": DeviceConfigField( + label="Read Only", widget_cls=ToggleSwitch, required=False, default=False + ), + "softwareTrigger": DeviceConfigField( + label="Software Trigger", widget_cls=ToggleSwitch, required=False, default=False + ), + "readoutPriority": DeviceConfigField( + label="Readout Priority", widget_cls=ReadoutPriorityComboBox, default="baseline" + ), + "onFailure": DeviceConfigField( + label="On Failure", widget_cls=OnFailureComboBox, default="retry" + ), + "userParameter": DeviceConfigField( + label="User Parameters", widget_cls=ParameterValueWidget, static=False + ), + "deviceTags": DeviceConfigField(label="Device Tags", widget_cls=DeviceTagsWidget, static=False), +} + +DEVICE_CONFIG_FIELDS = { + "prefix": DeviceConfigField( + label="Prefix", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS IOC prefix, e.g. X25DA-ES1-MOT:", + validation_callback=[validate_prefix], + ), + "read_pv": DeviceConfigField( + label="Read PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS read PV: e.g. X25DA-ES1-MOT:GET", + validation_callback=[validate_prefix], + ), + "write_pv": DeviceConfigField( + label="Write PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS write PV (if different from read_pv): e.g. X25DA-ES1-MOT:SET", + validation_callback=[validate_prefix], + ), + "limits": DeviceConfigField(label="Limits", widget_cls=LimitInputWidget, static=False), + "DEFAULT": DeviceConfigField(label="DEFAULT FIELD", widget_cls=InputLineEdit, static=False), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py new file mode 100644 index 000000000..a7b716cfa --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -0,0 +1,1128 @@ +""" +Module for a TableWidget for the device manager view. Row data is encapsulated +in DeviceTableRow entries. +""" + +from __future__ import annotations + +import traceback +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, 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 +from thefuzz import fuzz + +from bec_widgets.utils.bec_widget import BECWidget +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.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.messages import ConfigAction + +logger = bec_logger.logger + +_DeviceCfgIter = Iterable[dict[str, Any]] +# DeviceValidationResult: device_config, config_status, connection_status, error_message +_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] + +FUZZY_SEARCH_THRESHOLD = 80 + + +def is_match( + text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool +) -> bool: + """ + Check if the text matches any of the relevant keys in the row data. + + Args: + text (str): The text to search for. + row_data (dict[str, Any]): The row data to search in. + relevant_keys (list[str]): The keys to consider for searching. + enable_fuzzy (bool): Whether to use fuzzy matching. + Returns: + bool: True if a match is found, False otherwise. + """ + for key in relevant_keys: + data = str(row_data.get(key, "") or "") + if enable_fuzzy: + match_ratio = fuzz.partial_ratio(text.lower(), data.lower()) + if match_ratio >= FUZZY_SEARCH_THRESHOLD: + return True + else: + if text.lower() in data.lower(): + return True + return False + + +class TableSortOnHold: + """Context manager for putting table sorting on hold. Works with nested calls.""" + + def __init__(self, table: QtWidgets.QTableWidget) -> None: + self.table = table + self._call_depth = 0 + self._registered_methods = [] + + def register_on_hold_method( + self, method: Callable[[QtWidgets.QTableWidget, bool], None] + ) -> None: + """ + Register a method to be called when sorting is put on hold. + + Args: + method (Callable[[QtWidgets.QTableWidget, bool], None]): The method to register. + The method should accept the QTableWidget and a bool indicating + whether sorting is being enabled (True) or disabled (False). + """ + self._registered_methods.append(method) + + def __enter__(self): + """Enter the context manager""" + self._call_depth += 1 # Needed for nested calls + self.table.setSortingEnabled(False) + for method in self._registered_methods: + method(self.table, False) + + def __exit__(self, *exc): + """Exit the context manager""" + self._call_depth -= 1 # Remove nested calls + if self._call_depth == 0: # Only re-enable sorting on outermost exit + self.table.setSortingEnabled(True) + for method in self._registered_methods: + method(self.table, True) + + +class CenterIconDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to center icons in table cells.""" + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + # First draw the default cell (without icon) + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.icon = QtGui.QIcon() # Create empty icon to avoid default to be drawn at given position + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, option.widget + ) + # Check if there is an icon to draw + icon = index.data(QtCore.Qt.ItemDataRole.DecorationRole) + if not icon: + return + # Draw the icon centered in the cell + icon_size = option.decorationSize + if icon_size.isValid(): + size = icon_size + else: + size = icon.actualSize(option.rect.size()) + + x = option.rect.x() + (option.rect.width() - size.width()) // 2 + y = option.rect.y() + (option.rect.height() - size.height()) // 2 + + icon.paint(painter, QtCore.QRect(QtCore.QPoint(x, y), size)) + + +class CheckBoxDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to handle checkbox interactions in the table.""" + + # Signal to indicate a checkbox was clicked + checkbox_clicked = QtCore.Signal(int, int, bool) # row, column, checked + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + if model and (model.flags(index) & QtCore.Qt.ItemFlag.ItemIsUserCheckable): + old_state = QtCore.Qt.CheckState( + model.data(index, QtCore.Qt.ItemDataRole.CheckStateRole) + ) + new_state = ( + QtCore.Qt.CheckState.Unchecked + if old_state == QtCore.Qt.CheckState.Checked + else QtCore.Qt.CheckState.Checked + ) + model.setData(index, new_state, QtCore.Qt.ItemDataRole.CheckStateRole) + model.setData( + index, + new_state == QtCore.Qt.CheckState.Checked, + QtCore.Qt.ItemDataRole.UserRole, + ) + self.checkbox_clicked.emit( + index.row(), index.column(), new_state == QtCore.Qt.CheckState.Checked + ) + return True + return super().editorEvent(event, model, option, index) + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data < other_data + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data > other_data + return super().__gt__(other) + + +class DeviceTable(BECWidget, QtWidgets.QWidget): + """Custom table to display device configurations.""" + + RPC = False # TODO discuss if this should be available for RPC + + # 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) + # 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 + device_row_dbl_clicked = QtCore.Signal(dict) + # 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) + self.headers_key_map: dict[str, str] = { + "Valid": "valid", + "Connect": "connect", + "Name": "name", + "Device Class": "deviceClass", + "Readout Priority": "readoutPriority", + "On Failure": "onFailure", + "Device Tags": "deviceTags", + "Description": "description", + "Enabled": "enabled", + "Read Only": "readOnly", + "Software Trigger": "softwareTrigger", + } + + # General attributes + self._icon_size = (18, 18) + self._colors = get_accent_colors() + self._icons = get_validation_icons(self._colors, self._icon_size) + self._check_box_icons = { + "checked": material_icon( + "check_box", size=(24, 24), color=self._colors.default, convert_to_pixmap=False + ), + "unchecked": material_icon( + "check_box_outline_blank", + size=(24, 24), + color=self._colors.default, + convert_to_pixmap=False, + ), + } + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) + + # Table related attributes + self.row_data: dict[str, DeviceTableRow] = {} + self.table = QtWidgets.QTableWidget(self) + self.table_sort_on_hold = TableSortOnHold(self.table) + self._setup_table() + self.table_sort_on_hold.register_on_hold_method(self._resize_table_policy) + self.table_sort_on_hold.register_on_hold_method(self._set_table_signals_on_hold) + + # Search related attributes + self._searchable_keys: list[str] = ["name", "deviceClass", "deviceTags", "description"] + self._hidden_rows: set[int] = set() + self._enable_fuzzy_search: bool = True + self._setup_search() + + # Add components to layout + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) + + # 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 + 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 + # ------------------------------------------------------------------------- + + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ): + """Handle selection changes in the table.""" + rows = set() + for index in selected.indexes(): + row = index.row() + rows.add(row) + selected_configs = [] + for row in rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + cfg = deepcopy(row_data.data) + cfg.pop("name") + selected_configs.append({device_name: cfg}) + self.selected_devices.emit(selected_configs) + + def _on_cell_double_clicked(self, row: int, column: int): + """Handle double-click events on table cells.""" + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + self.device_row_dbl_clicked.emit(row_data.data) + + def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Customize event filtering for table interactions.""" + if source is self.table: + if event.type() == QtCore.QEvent.Type.KeyPress: + if event.key() in (QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete): + configs = self.get_selected_device_configs() + if configs: + if self._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.remove_device_configs(configs) + return True # Event handled + if event.key() == QtCore.Qt.Key.Key_Escape: + self.table.clearSelection() + return True # handled + return super().eventFilter(source, event) + + def _on_table_checkbox_clicked(self, row: int, column: int, checked: bool): + """Handle checkbox clicks in the table.""" + name_index = list(self.headers_key_map.values()).index("name") + device_name = self._get_cell_data(row, name_index) + row_data = self.row_data.get(device_name) + if not row_data: + return + row_data.data[self.headers_key_map[list(self.headers_key_map.keys())[column]]] = checked + self._on_device_row_data_changed(row_data.data) + + def _on_device_row_data_changed(self, data: dict): + """Handle data change events from device rows.""" + device_name = data.get("name", None) + cfg = deepcopy(data) + cfg.pop("name") + self.selected_devices.emit([{device_name: cfg}]) + self.device_config_in_sync_with_redis.emit(self._is_config_in_sync_with_redis()) + + def _apply_row_filter(self, text_input: str): + """Apply a filter to the table rows based on the filter text.""" + for row in range(self.table.rowCount()): + device_name = self._get_cell_data(row, 2) # Name column + if not device_name: + continue + row_data = self.row_data.get(device_name) + if not row_data: + continue + if is_match( + text_input, row_data.data, self._searchable_keys, self._enable_fuzzy_search + ): + self.table.setRowHidden(row, False) + self._hidden_rows.discard(row) + else: + self.table.setRowHidden(row, True) + self._hidden_rows.add(row) + + def _state_change_fuzzy_search(self, enabled: int): + """Handle state changes for the fuzzy search toggle.""" + self._enable_fuzzy_search = not bool(enabled) + # Re-apply filter with updated fuzzy search setting + current_text = self.search_input.text() + self._apply_row_filter(current_text) + + # ------------------------------------------------------------------------- + # Custom Dialog + # ------------------------------------------------------------------------- + + def _remove_configs_dialog(self, device_names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + device_names (list[str]): List of device names to be removed. + + Returns: + bool: True if the user confirmed removal, False otherwise. + """ + msg = QtWidgets.QMessageBox(self) + msg.setIcon(QtWidgets.QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{device_names[0]}'?" + if len(device_names) == 1 + else f"Remove {len(device_names)} devices?" + ) + separator = "\n" if len(device_names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(device_names)) + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel + ) + msg.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + + res = msg.exec_() + if res == QtWidgets.QMessageBox.StandardButton.Ok: + return True + return False + + # ------------------------------------------------------------------------- + # Setup table + # ------------------------------------------------------------------------- + def _setup_table(self): + """Initializes the table configuration and headers.""" + # Temporary instance to get headers dynamically + headers = list(self.headers_key_map.keys()) + self.table.setColumnCount(len(headers)) + self.table.setHorizontalHeaderLabels(headers) + # Smooth scrolling + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + + # Hide vertical header + self.table.verticalHeader().setVisible(False) + + # Column resize policies + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(6, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(7, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(8, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(9, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(10, QtWidgets.QHeaderView.ResizeMode.Fixed) + + for sizes, col in [ + (0, 85), + (1, 85), + (2, 200), + (3, 200), + (6, 200), + (7, 200), + (8, 90), + (9, 90), + (10, 120), + ]: + self.table.setColumnWidth(sizes, col) + + # Ensure column widths stay fixed + header.setStretchLastSection(False) + + # Sorting + self.table.setSortingEnabled(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(2, QtCore.Qt.SortOrder.AscendingOrder) # Default sort by name + + # Selection behavior + self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.table.horizontalHeader().setHighlightSections(False) + + # Set delegate for checkboxes + checkbox_delegate = CheckBoxDelegate(self.table) + icon_delegate = CenterIconDelegate(self.table) + self.table.setItemDelegateForColumn(0, icon_delegate) # Config status + self.table.setItemDelegateForColumn(1, icon_delegate) # Connection status + self.table.setWordWrap(True) + for col in (8, 9, 10): # enabled, readOnly, softwareTrigger + self.table.setItemDelegateForColumn(col, checkbox_delegate) + checkbox_delegate.checkbox_clicked.connect(self._on_table_checkbox_clicked) + + def _set_table_signals_on_hold(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable table signals.""" + if enable: + table.blockSignals(False) + else: + table.blockSignals(True) + + def _resize_table_policy(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable column resizing.""" + if enable: + table.resizeColumnToContents(2) # Name + table.resizeColumnToContents(3) # Device Class + # table.resizeRowsToContents() + + def _setup_search(self): + """Create components related to the search functionality""" + + # Create search bar + self.search_layout = QtWidgets.QHBoxLayout() + self.search_label = QtWidgets.QLabel("Search:") + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Filter devices (approximate matching)...") + self.search_input.setClearButtonEnabled(True) + self.search_input.textChanged.connect(self._apply_row_filter) + self.search_layout.addWidget(self.search_label) + self.search_layout.addWidget(self.search_input) + + # Add exact match toggle + self.fuzzy_layout = QtWidgets.QHBoxLayout() + self.fuzzy_label = QtWidgets.QLabel("Exact Match:") + self.fuzzy_is_disabled = QtWidgets.QCheckBox() + + self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search) + self.fuzzy_is_disabled.setToolTip( + "Enable approximate matching (OFF) and exact matching (ON)" + ) + self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") + self.fuzzy_layout.addWidget(self.fuzzy_label) + self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) + self.fuzzy_layout.addStretch() + + # Add both search components to the layout + self.search_controls = QtWidgets.QHBoxLayout() + self.search_controls.addLayout(self.search_layout) + self.search_controls.addSpacing(20) # Add some space between the search box and toggle + self.search_controls.addLayout(self.fuzzy_layout) + QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + + # ------------------------------------------------------------------------- + # Row Management, internal methods. + # ------------------------------------------------------------------------- + + def _add_row( + self, + data: dict, + config_status: ConfigStatus | int, + connection_status: ConnectionStatus | int, + ): + """ + Adds a new row at the bottom and populates it with data. The row widgets + are stored in self.row_widgets for easy access. Consider to disable sorting + when adding rows as this method is not responsible for maintaining sort order. + + Args: + data (dict): The device data to populate the row. + config_status (ConfigStatus | int): The configuration validation status. + connection_status (ConnectionStatus | int): The connection status. + """ + with self.table_sort_on_hold: + if data["name"] in self.row_data: + logger.warning(f"Overwriting existing device row for {data['name']}") + self._remove_rows_by_name([data["name"]]) + row_index = self.table.rowCount() + self.table.insertRow(row_index) + + # Create row for the table + device_row = DeviceTableRow(data=data) + device_row.set_validation_status(config_status, connection_status) + + # Populate cells + self._populate_device_row_cells(row_index, device_row) + + def _populate_device_row_cells(self, row: int, device_row: DeviceTableRow): + """Populate the cells of a given row with the widgets from the DeviceTableRow.""" + with self.table_sort_on_hold: + config_status, connect_status = device_row.validation_status + column_keys = list(self.headers_key_map.values()) + for ii, key in enumerate(column_keys): + if key in ("enabled", "readOnly", "softwareTrigger"): # flags for checkboxes + item = SortTableItem() + item.setFlags( + item.flags() + | QtCore.Qt.ItemFlag.ItemIsUserCheckable + | QtCore.Qt.ItemFlag.ItemIsEnabled + ) + item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + elif key in ("valid", "connect"): # status columns + item = SortTableItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignCenter | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + item.setIcon( + self._icons["connection_status"][connect_status] + if key == "connect" + else self._icons["config_status"][config_status] + ) + else: + item = QtWidgets.QTableWidgetItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(row, ii, item) # +2 offset for status columns + self.__update_device_row_data(row, device_row.data) + + def __update_device_row_data(self, row: int, data: dict): + """ + Update an existing device row with new data. + + Args: + row (int): The row index to update. + data (dict): The device data to populate the row. + """ + # Update stored row data + if data["name"] in self.row_data: + self.row_data[data["name"]].set_data(data) + else: + self.row_data[data["name"]] = DeviceTableRow(data) + # Update table cells + with self.table_sort_on_hold: + column_keys = list(self.headers_key_map.values()) # map columns + for key, value in data.items(): + if key not in column_keys: + continue # Skip userParameters and deviceConfig + column = column_keys.index(key) + item = self.table.item(row, column) + if not item: + continue + if key in ("enabled", "readOnly", "softwareTrigger"): + item.setCheckState( + QtCore.Qt.CheckState.Checked if value else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, value) + item.setText("") # No text for checkboxes + elif key == "deviceTags": + item.setText( + ", ".join(value) if isinstance(value, (list, set, tuple)) else str(value) + ) + elif key == "deviceClass": + item.setText( + value.split(".")[-1] + ) # Only show the DeviceClass, not the full module + else: + if value is None: + value = "" + item.setText(str(value)) + self._update_device_row_status( + row, + self.row_data[data["name"]].validation_status[0], + self.row_data[data["name"]].validation_status[1], + ) + self.table.resizeRowToContents(row) + self._on_device_row_data_changed(self.row_data[data["name"]].data) + return True + + def _update_device_row_status( + self, row: int, config_status: int, connection_status: int + ) -> bool: + """ + Update an existing device row's validation status. + + Args: + device_name (str): The name of the device. + config_status (int): The configuration validation status. + connection_status (int): The connection status. + """ + with self.table_sort_on_hold: + item = self.table.item(row, 0) # Config status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, config_status) + item.setIcon(self._icons["config_status"][config_status]) + item = self.table.item(row, 1) # Connect status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, connection_status) + item.setIcon(self._icons["connection_status"][connection_status]) + + # Update the stored row data as well + device_name = self._get_cell_data(row, 2) # Name column + device_row = self.row_data.get(device_name, None) + if not device_row: + return False + device_row: DeviceTableRow + device_row.set_validation_status(config_status, connection_status) + return True + + def _get_cell_data(self, row: int, column: int) -> str | bool | None: + """ + Get the data from a specific cell. + + Args: + row (int): The row index. + column (int): The column index. + """ + item = self.table.item(row, column) + if item is None: + return None + if column in (8, 9, 10): # Checkboxes + return item.checkState() == QtCore.Qt.CheckState.Checked + return item.text() + + def _update_row(self, data: dict) -> int | None: + """ + Update an existing row with new data. + + Args: + data (dict): The device data to populate the row. + Returns: + int | None: The row index if updated, else None. + """ + device_row = self.row_data.get(data.get("name"), {}) + if self._compare_configs(device_row.data, data): + return None # No update needed + row = self._find_row_by_name(data.get("name", "")) + if row is not None: + self.__update_device_row_data(row, data) + return row + + def _compare_configs(self, cfg1: dict, cfg2: dict) -> bool: + """Compare two device configurations for equality.""" + try: + cfg1_model = DeviceModel.model_validate(cfg1) + cfg2_model = DeviceModel.model_validate(cfg2) + return cfg1_model == cfg2_model + except Exception as e: + logger.error(f"Error comparing device configs: {e}") + return False + + def _clear_table(self): + """Remove all rows.""" + with self.table_sort_on_hold: + n_rows = self.table.rowCount() + for _ in range(n_rows): + self.table.removeRow(0) + self.row_data.clear() + + def _find_row_by_name(self, name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + data = self._get_cell_data(row, 2) + if data and data == name: + return row + return None + + def _remove_rows_by_name(self, device_names: list[str]): + """ + Remove a row by device name. + + Args: + device_name (str): The name of the device to remove. + """ + if not device_names: + return + with self.table_sort_on_hold: + for device_name in device_names: + row = self._find_row_by_name(device_name) + if row is None: + logger.warning(f"Device {device_name} not found in table for removal.") + return + self.table.removeRow(row) + self.row_data.pop(device_name, None) + + def _is_config_in_sync_with_redis(self): + """Check if the current config is in sync with Redis.""" + if ( + not self.client + or not self.client.device_manager + or not self.client.device_manager.devices + ): + return False # No proper client connection + redis_config = [ + DeviceModel.model_validate(device._config) + for device in self.client.device_manager.devices.values() + ] + try: + current_config = [ + DeviceModel.model_validate(row_data.data) for row_data in self.row_data.values() + ] + if redis_config == current_config: + return True + else: + return False + except Exception as e: + 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 + # ------------------------------------------------------------------------- + + def get_device_config(self) -> list[dict]: + """ + Get the current device configurations in the table. + + Returns: + list[dict]: The list of device configurations. + """ + cfgs = [ + {"name": device_name, **row_data.data} + for device_name, row_data in self.row_data.items() + ] + return cfgs + + def get_validation_results(self) -> dict[str, Tuple[dict, int, int]]: + """ + Get the current device validation results in the table. + + Returns: + dict[str, Tuple[dict, int, int]]: Dictionary mapping of device name to + (device config, config status, connection status). + """ + return { + row_data.data.get("name"): (row_data.data, *row_data.validation_status) + for row_data in self.row_data.values() + if row_data.data.get("name") is not None + } + + def get_selected_device_configs(self) -> list[dict]: + """ + Get the currently selected device configurations in the table. + + Returns: + list[dict]: The list of selected device configurations. + """ + selected_configs = [] + selected_rows = set() + for index in self.table.selectionModel().selectedIndexes(): + selected_rows.add(index.row()) + for row in selected_rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + selected_configs.append(row_data.data) + return selected_configs + + # ------------------------------------------------------------------------- + # Public API to be called via signals/slots + # ------------------------------------------------------------------------- + + @SafeSlot(list, bool) + def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): + """ + 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) + 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) + 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) + + @SafeSlot() + def clear_device_configs(self): + """Clear the device configs. Skips validation by default.""" + self.set_busy(True) + 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 + 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) + + @SafeSlot(list, bool) + def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): + """ + 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. + + 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) + already_in_table = [] + not_in_table = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] in self.row_data: + already_in_table.append(cfg) + else: + not_in_table.append(cfg) + with self.table_sort_on_hold: + # 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 + + 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) + 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) + + @SafeSlot(list, bool) + def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): + """ + 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) + cfgs_updated = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] not in self.row_data: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + cfgs_updated.append(cfg) + continue + # Update existing row if device config has changed + row = self._update_row(cfg) + if row is not None: + cfgs_updated.append(cfg) + self.device_configs_changed.emit(cfgs_updated, True, skip_validation) + 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) + + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): + """ + Remove devices from the config. + + Args: + device_configs (dict[str, dict]): The device configs to remove. + """ + self.set_busy(True) + 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 + 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) + + @SafeSlot(str) + def remove_device(self, device_name: str): + """ + Remove a device from the config. + + Args: + device_name (str): The name of the device to remove. + """ + self.set_busy(True) + 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) + 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 + 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) + + @SafeSlot(list) + def update_multiple_device_validations(self, validation_results: _ValidationResultIter): + """ + Slot to update multiple device validation statuses. This is recommended and more + efficient than updating individual device validation statuses which may affect + the performance of the UI when many devices are being updated in quick succession. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to update. + """ + self.set_busy(True) + 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) + + @SafeSlot(dict, int, int, str) + def update_device_validation( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ) -> None: + """ + Update the validation status of a device. If multiple devices are being updated in a batch, + consider using the `update_multiple_device_validations` method instead for better performance. + + Args: + + """ + self.set_busy(True) + 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) + 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 + # update of a validation status. + self.table.setSortingEnabled(False) + self._update_device_row_status(row, config_status, connection_status) + 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) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py new file mode 100644 index 000000000..4a777e08a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py @@ -0,0 +1,56 @@ +"""Module with custom table row for the device manager device table view.""" + +from bec_lib.atlas_models import Device as DeviceModel + +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, +) + + +class DeviceTableRow: + """ + Custom class to hold data and validation status for a device table row. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + + def __init__(self, data: list[str, dict] | None = None): + """Initialize the DeviceTableRow with optional data. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + self._data = {} + self.validation_status: tuple[int, int] = (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + self.set_data(data or {}) + + @property + def data(self) -> dict: + """Get the current data from the row widgets as a dictionary.""" + return self._data + + def set_data(self, data: DeviceModel | dict) -> None: + """Set the data for the row widgets.""" + if isinstance(data, dict): + data = DeviceModel.model_validate(data) + old_data = DeviceModel.model_validate(self._data) if self._data else None + if old_data is not None and old_data == data: + return # No change needed + self._data = data.model_dump() + self.set_validation_status(ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + + def set_validation_status( + self, valid: ConfigStatus | int, connect_status: ConnectionStatus | int + ) -> None: + """ + Set the validation and connection status icons. + + Args: + valid (ConfigStatus | int): The configuration validation status. + connect_status (ConnectionStatus | int): The connection status. + """ + valid = int(valid) + connect_status = int(connect_status) + self.validation_status = valid, connect_status diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py deleted file mode 100644 index b541916b6..000000000 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ /dev/null @@ -1,631 +0,0 @@ -"""Module with the device table view implementation.""" - -from __future__ import annotations - -import copy -import json - -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy import QtCore, QtGui, QtWidgets -from thefuzz import fuzz - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeSlot - -logger = bec_logger.logger - -# Threshold for fuzzy matching, careful with adjusting this. 80 seems good -FUZZY_SEARCH_THRESHOLD = 80 - - -class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): - """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - - @staticmethod - def dict_to_str(d: dict) -> str: - """Convert a dictionary to a formatted string.""" - return json.dumps(d, indent=4) - - def helpEvent(self, event, view, option, index): - """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.ToolTip: - return super().helpEvent(event, view, option, index) - model: DeviceFilterProxyModel = index.model() - model_index = model.mapToSource(index) - row_dict = model.sourceModel().row_data(model_index) - row_dict.pop("description", None) - QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view) - return True - - -class CenterCheckBoxDelegate(DictToolTipDelegate): - """Custom checkbox delegate to center checkboxes in table cells.""" - - def __init__(self, parent=None): - super().__init__(parent) - colors = get_accent_colors() - self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default - ) - self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default - ) - - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - self._icon_checked.setColor(colors.default) - self._icon_unchecked.setColor(colors.default) - - def paint(self, painter, option, index): - value = index.model().data(index, QtCore.Qt.CheckStateRole) - if value is None: - super().paint(painter, option, index) - return - - # Choose icon based on state - pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked - - # Draw icon centered - rect = option.rect - pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) - - def editorEvent(self, event, model, option, index): - if event.type() != QtCore.QEvent.MouseButtonRelease: - return False - current = model.data(index, QtCore.Qt.CheckStateRole) - new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked - return model.setData(index, new_state, QtCore.Qt.CheckStateRole) - - -class WrappingTextDelegate(DictToolTipDelegate): - """Custom delegate for wrapping text in table cells.""" - - def paint(self, painter, option, index): - text = index.model().data(index, QtCore.Qt.DisplayRole) - if not text: - return super().paint(painter, option, index) - - painter.save() - painter.setClipRect(option.rect) - text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop - painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) - painter.restore() - - def sizeHint(self, option, index): - text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # if not text: - # return super().sizeHint(option, index) - - # Use the actual column width - table = index.model().parent() # or store reference to QTableView - column_width = table.columnWidth(index.column()) # - 8 - - doc = QtGui.QTextDocument() - doc.setDefaultFont(option.font) - doc.setTextWidth(column_width) - doc.setPlainText(text) - - layout_height = doc.documentLayout().documentSize().height() - height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off - return QtCore.QSize(column_width, height) - - -class DeviceTableModel(QtCore.QAbstractTableModel): - """ - Custom Device Table Model for managing device configurations. - - Sort logic is implemented directly on the data of the table view. - """ - - def __init__(self, device_config: list[dict] | None = None, parent=None): - super().__init__(parent) - self._device_config = device_config or [] - self.headers = [ - "name", - "deviceClass", - "readoutPriority", - "enabled", - "readOnly", - "deviceTags", - "description", - ] - self._checkable_columns_enabled = {"enabled": True, "readOnly": True} - - ############################################### - ########## Overwrite custom Qt methods ######## - ############################################### - - def rowCount(self, parent=QtCore.QModelIndex()) -> int: - return len(self._device_config) - - def columnCount(self, parent=QtCore.QModelIndex()) -> int: - return len(self.headers) - - def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: - return self.headers[section] - return None - - def row_data(self, index: QtCore.QModelIndex) -> dict: - """Return the row data for the given index.""" - if not index.isValid(): - return {} - return copy.deepcopy(self._device_config[index.row()]) - - def data(self, index, role=QtCore.Qt.DisplayRole): - """Return data for the given index and role.""" - if not index.isValid(): - return None - row, col = index.row(), index.column() - key = self.headers[col] - value = self._device_config[row].get(key) - - if role == QtCore.Qt.DisplayRole: - if key in ("enabled", "readOnly"): - return bool(value) - if key == "deviceTags": - return ", ".join(str(tag) for tag in value) if value else "" - return str(value) if value is not None else "" - if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): - return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked - if role == QtCore.Qt.TextAlignmentRole: - if key in ("enabled", "readOnly"): - return QtCore.Qt.AlignCenter - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - if role == QtCore.Qt.FontRole: - font = QtGui.QFont() - return font - return None - - def flags(self, index): - """Flags for the table model.""" - if not index.isValid(): - return QtCore.Qt.NoItemFlags - key = self.headers[index.column()] - - if key in ("enabled", "readOnly"): - base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemIsUserCheckable - else: - return base_flags # disable editing but still visible - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: - """ - Method to set the data of the table. - - Args: - index (QModelIndex): The index of the item to modify. - value (Any): The new value to set. - role (Qt.ItemDataRole): The role of the data being set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if not index.isValid(): - return False - key = self.headers[index.column()] - row = index.row() - - if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: - if not self._checkable_columns_enabled.get(key, True): - return False # ignore changes if column is disabled - self._device_config[row][key] = value == QtCore.Qt.Checked - self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole]) - return True - return False - - #################################### - ############ Public methods ######## - #################################### - - def get_device_config(self) -> list[dict]: - """Return the current device config (with checkbox updates applied).""" - return self._device_config - - def set_checkbox_enabled(self, column_name: str, enabled: bool): - """ - Enable/Disable the checkbox column. - - Args: - column_name (str): The name of the column to modify. - enabled (bool): Whether the checkbox should be enabled or disabled. - """ - if column_name in self._checkable_columns_enabled: - self._checkable_columns_enabled[column_name] = enabled - col = self.headers.index(column_name) - top_left = self.index(0, col) - bottom_right = self.index(self.rowCount() - 1, col) - self.dataChanged.emit( - top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole] - ) - - def set_device_config(self, device_config: list[dict]): - """ - Replace the device config. - - Args: - device_config (list[dict]): The new device config to set. - """ - self.beginResetModel() - self._device_config = list(device_config) - self.endResetModel() - - @SafeSlot(dict) - def add_device(self, device: dict): - """ - Add an extra device to the device config at the bottom. - - Args: - device (dict): The device configuration to add. - """ - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(device) - self.endInsertRows() - - @SafeSlot(int) - def remove_device_by_row(self, row: int): - """ - Remove one device row by index. This maps to the row to the source of the data model - - Args: - row (int): The index of the device row to remove. - """ - if 0 <= row < len(self._device_config): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._device_config.pop(row) - self.endRemoveRows() - - @SafeSlot(list) - def remove_devices_by_rows(self, rows: list[int]): - """ - Remove multiple device rows by their indices. - - Args: - rows (list[int]): The indices of the device rows to remove. - """ - for row in sorted(rows, reverse=True): - self.remove_device_by_row(row) - - @SafeSlot(str) - def remove_device_by_name(self, name: str): - """ - Remove one device row by name. - - Args: - name (str): The name of the device to remove. - """ - for row, device in enumerate(self._device_config): - if device.get("name") == name: - self.remove_device_by_row(row) - break - - -class BECTableView(QtWidgets.QTableView): - """Table View with custom keyPressEvent to delete rows with backspace or delete key""" - - def keyPressEvent(self, event) -> None: - """ - Delete selected rows with backspace or delete key - - Args: - event: keyPressEvent - """ - if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): - return super().keyPressEvent(event) - - proxy_indexes = self.selectedIndexes() - if not proxy_indexes: - return - - # Get unique rows (proxy indices) in reverse order so removal indexes stay valid - proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True) - # Map to source model rows - source_rows = [ - self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows - ] - - model: DeviceTableModel = self.model().sourceModel() # access underlying model - # Delegate confirmation and removal to helper - removed = self._confirm_and_remove_rows(model, source_rows) - if not removed: - return - - def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: - """ - Prompt the user to confirm removal of rows and remove them from the model if accepted. - - Returns True if rows were removed, False otherwise. - """ - cfg = model.get_device_config() - names = [str(cfg[r].get("name", "")) for r in sorted(source_rows)] - - msg = QtWidgets.QMessageBox(self) - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setWindowTitle("Confirm remove devices") - if len(names) == 1: - msg.setText(f"Remove device '{names[0]}'?") - else: - msg.setText(f"Remove {len(names)} devices?") - msg.setInformativeText("\n".join(names)) - msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) - msg.setDefaultButton(QtWidgets.QMessageBox.Cancel) - - res = msg.exec_() - if res == QtWidgets.QMessageBox.Ok: - model.remove_devices_by_rows(source_rows) - # TODO add signal for removed devices - return True - return False - - -class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): - - def __init__(self, parent=None): - super().__init__(parent) - self._hidden_rows = set() - self._filter_text = "" - self._enable_fuzzy = True - self._filter_columns = [0, 1] # name and deviceClass for search - - def hide_rows(self, row_indices: list[int]): - """ - Hide specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to hide. - """ - self._hidden_rows.update(row_indices) - self.invalidateFilter() - - def show_rows(self, row_indices: list[int]): - """ - Show specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to show. - """ - self._hidden_rows.difference_update(row_indices) - self.invalidateFilter() - - def show_all_rows(self): - """ - Show all rows in the model. - """ - self._hidden_rows.clear() - self.invalidateFilter() - - @SafeSlot(int) - def disable_fuzzy_search(self, enabled: int): - self._enable_fuzzy = not bool(enabled) - self.invalidateFilter() - - def setFilterText(self, text: str): - self._filter_text = text.lower() - self.invalidateFilter() - - def filterAcceptsRow(self, source_row: int, source_parent) -> bool: - # No hidden rows, and no filter text - if not self._filter_text and not self._hidden_rows: - return True - # Hide hidden rows - if source_row in self._hidden_rows: - return False - # Check the filter text for each row - model = self.sourceModel() - text = self._filter_text.lower() - for column in self._filter_columns: - index = model.index(source_row, column, source_parent) - data = str(model.data(index, QtCore.Qt.DisplayRole) or "") - if self._enable_fuzzy is True: - match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) - if match_ratio >= FUZZY_SEARCH_THRESHOLD: - return True - else: - if text in data.lower(): - return True - return False - - -class DeviceTableView(BECWidget, QtWidgets.QWidget): - """Device Table View for the device manager.""" - - RPC = False - PLUGIN = False - devices_removed = QtCore.Signal(list) - - def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent, theme_update=True) - - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(4) - - # Setup table view - self._setup_table_view() - # Setup search view, needs table proxy to be iniditate - self._setup_search() - # Add widgets to main layout - self.layout.addLayout(self.search_controls) - self.layout.addWidget(self.table) - - def _setup_search(self): - """Create components related to the search functionality""" - - # Create search bar - self.search_layout = QtWidgets.QHBoxLayout() - self.search_label = QtWidgets.QLabel("Search:") - self.search_input = QtWidgets.QLineEdit() - self.search_input.setPlaceholderText( - "Filter devices (approximate matching)..." - ) # Default to fuzzy search - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self.proxy.setFilterText) - self.search_layout.addWidget(self.search_label) - self.search_layout.addWidget(self.search_input) - - # Add exact match toggle - self.fuzzy_layout = QtWidgets.QHBoxLayout() - self.fuzzy_label = QtWidgets.QLabel("Exact Match:") - self.fuzzy_is_disabled = QtWidgets.QCheckBox() - - self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search) - self.fuzzy_is_disabled.setToolTip( - "Enable approximate matching (OFF) and exact matching (ON)" - ) - self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") - self.fuzzy_layout.addWidget(self.fuzzy_label) - self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) - self.fuzzy_layout.addStretch() - - # Add both search components to the layout - self.search_controls = QtWidgets.QHBoxLayout() - self.search_controls.addLayout(self.search_layout) - self.search_controls.addSpacing(20) # Add some space between the search box and toggle - self.search_controls.addLayout(self.fuzzy_layout) - QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) - - def _setup_table_view(self) -> None: - """Setup the table view.""" - # Model + Proxy - self.table = BECTableView(self) - self.model = DeviceTableModel(parent=self.table) - self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self.model) - self.table.setModel(self.proxy) - self.table.setSortingEnabled(True) - - # Delegates - self.checkbox_delegate = CenterCheckBoxDelegate(self.table) - self.wrap_delegate = WrappingTextDelegate(self.table) - self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description - - # Column resize policies - # TODO maybe we need here a flexible header options as deviceClass - # may get quite long for beamlines plugin repos - header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly - # TODO maybe better stretch... - header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description - self.table.setColumnWidth(3, 82) - self.table.setColumnWidth(4, 82) - - # Ensure column widths stay fixed - header.setMinimumSectionSize(70) - header.setDefaultSectionSize(90) - - # Enable resizing of column - header.sectionResized.connect(self.on_table_resized) - - # Selection behavior - self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.table.horizontalHeader().setHighlightSections(False) - - # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) - - def device_config(self) -> list[dict]: - """Get the device config.""" - return self.model.get_device_config() - - def apply_theme(self, theme: str | None = None): - self.checkbox_delegate.apply_theme(theme) - - ###################################### - ########### Slot API ################# - ###################################### - - @SafeSlot(int, int, int) - def on_table_resized(self, column, old_width, new_width): - """Handle changes to the table column resizing.""" - if column != len(self.model.headers) - 1: - return - - for row in range(self.table.model().rowCount()): - index = self.table.model().index(row, column) - delegate = self.table.itemDelegate(index) - option = QtWidgets.QStyleOptionViewItem() - height = delegate.sizeHint(option, index).height() - self.table.setRowHeight(row, height) - - ###################################### - ##### Ext. Slot API ################# - ###################################### - - @SafeSlot(list) - def set_device_config(self, config: list[dict]): - """ - Set the device config. - - Args: - config (list[dict]): The device config to set. - """ - self.model.set_device_config(config) - - @SafeSlot() - def clear_device_config(self): - """ - Clear the device config. - """ - self.model.set_device_config([]) - - @SafeSlot(dict) - def add_device(self, device: dict): - """ - Add a device to the config. - - Args: - device (dict): The device to add. - """ - self.model.add_device(device) - - @SafeSlot(int) - @SafeSlot(str) - def remove_device(self, dev: int | str): - """ - Remove the device from the config either by row id, or device name. - - Args: - dev (int | str): The device to remove, either by row id or device name. - """ - if isinstance(dev, int): - # TODO test this properly, check with proxy index and source index - # Use the proxy model to map to the correct row - model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0)) - self.model.remove_device_by_row(model_source_index.row()) - return - if isinstance(dev, str): - self.model.remove_device_by_name(dev) - return - - -if __name__ == "__main__": - import sys - - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - window = DeviceTableView() - # pylint: disable=protected-access - config = window.client.device_manager._get_redis_device_config() - window.set_device_config(config) - window.show() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py new file mode 100644 index 000000000..2202efc3d --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,119 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import traceback + +import yaml +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class DMConfigView(QtWidgets.QWidget): + """Widget to show the config of a selected device in YAML format.""" + + RPC = False + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget(parent=self) + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + # Overlay widget + self._overlay_text = "Select a single device to view its config." + self._overlay_widget = QtWidgets.QLabel(text=self._overlay_text) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + """Customize the Monaco editor for YAML display.""" + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + self.monaco_editor.set_readonly(True) + self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) + self.monaco_editor.editor.set_line_numbers_mode("off") + + def _customize_overlay(self): + """Customize the overlay widget.""" + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: list[dict]): + """ + Handle selection of a device from the device table. If more than one device is selected, + show an overlay message. Otherwise, display the device config in YAML format. + + Args: + device (list[dict]): The selected device configuration. + """ + if len(device) != 1: + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + else: + try: + # Cast set to list to ensure proper YAML dumping + cfg = device[0] + for k, v in cfg.items(): + if isinstance(v, set): + cfg[k] = list(v) + text = yaml.dump(cfg, default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + except Exception: + content = traceback.format_exc() + logger.error(f"Error converting device to YAML:\n{content}") + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + self.monaco_editor.set_readonly(False) # Enable editing + text = text.rstrip() + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +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("dark") + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + config_view = DMConfigView() + layout.addWidget(config_view) + combo_box = QtWidgets.QComboBox() + config = config_view.client.device_manager._get_redis_device_config() + combo_box.addItems([""] + [f"{v} : {item.get('name', '')}" for v, item in enumerate(config)]) + + def on_select(text): + if text == "": + config_view.on_select_config([]) + else: + index = int(text.split(" : ")[0]) + config_view.on_select_config([config[index]]) + + combo_box.currentTextChanged.connect(on_select) + layout.addWidget(combo_box) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py new file mode 100644 index 000000000..cb990fd68 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -0,0 +1,132 @@ +"""Module to visualize the docstring of a device class.""" + +from __future__ import annotations + +import inspect +import re +import textwrap + +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import get_plugin_class +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + +try: + import ophyd + import ophyd_devices + + READY_TO_VIEW = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + ophyd = None + + +def docstring_to_markdown(obj) -> str: + """ + Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown. + """ + raw = inspect.getdoc(obj) or "*No docstring available.*" + + # Dedent and normalize newlines + text = textwrap.dedent(raw).strip() + + md = "" + if hasattr(obj, "__name__"): + md += f"# {obj.__name__}\n\n" + + # Highlight section headers for Markdown + headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] + for h in headers: + text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) + + # Preserve code blocks (4+ space indented lines) + def fence_code(match: re.Match) -> str: + block = re.sub(r"^ {4}", "", match.group(0), flags=re.M) + return f"```\n{block}\n```" + + doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text) + + # Preserve normal line breaks for Markdown + lines = doc.splitlines() + processed_lines = [] + for line in lines: + if line.strip() == "": + processed_lines.append("") + else: + processed_lines.append(line + " ") + doc = "\n".join(processed_lines) + + md += doc + return md + + +class DocstringView(QtWidgets.QTextEdit): + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setReadOnly(True) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + if not READY_TO_VIEW: + self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") + self.setEnabled(False) + return + + def _set_text(self, text: str): + self.setReadOnly(False) + self.setMarkdown(text) + self.setReadOnly(True) + + @SafeSlot(list) + def on_select_config(self, device: list[dict]): + if len(device) != 1: + self._set_text("") + return + device_name = list(device[0].keys())[0] + device_class = device[0][device_name].get("deviceClass", "") + self.set_device_class(device_class) + + @SafeSlot(str) + def set_device_class(self, device_class_str: str) -> None: + if not READY_TO_VIEW: + return + try: + module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) + except Exception: + logger.exception("Error retrieving docstring") + self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + config_view = DocstringView() + config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera") + layout.addWidget(config_view) + combo = QtWidgets.QComboBox() + combo.addItems( + [ + "", + "ophyd_devices.sim.sim_camera.SimCamera", + "ophyd.EpicsSignalWithRBV", + "ophyd.EpicsMotor", + "csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS", + ] + ) + combo.currentTextChanged.connect(config_view.set_device_class) + layout.addWidget(combo) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py new file mode 100644 index 000000000..829937707 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py @@ -0,0 +1,8 @@ +from .ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from .validation_list_item import ValidationButton, ValidationListItem diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py new file mode 100644 index 000000000..a2cae41a0 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -0,0 +1,913 @@ +""" +Module with a test widget that allows to run the ophyd_devices static tests +utilities for a device config test. Results are displayed in two lists (running, completed). +In addition, it allows to configure the test parameters. + +-> Connect: Try to establish a connection to the device +-> Timeout: Timeout for connection attempt. Default here is 5s. +-> Force Connect: To force connection even if already connected. + Mostly relevant for ADBase integrations. +""" + +import queue +import weakref +from typing import Any +from uuid import uuid4 + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_list import BECList +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + ValidationButton, + ValidationListItem, + format_error_to_md, + get_validation_icons, +) + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server # type: ignore + import ophyd_devices # type: ignore + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class DeviceTestResult(QtCore.QObject): + """Simple object to inject device validation signal to DeviceTest QRunnable.""" + + # ValidationResult: device_config, config_status, connection_status, error_message + device_validated = QtCore.Signal(dict, int, int, str) + device_validation_started = QtCore.Signal(str) + + +class DeviceTest(QtCore.QRunnable): + """QRunnable to run a device test in the QT thread pool.""" + + def __init__( + self, + device_model: DeviceTestModel, + 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.signals = DeviceTestResult() + self.device_config = device_model.device_config + self.enable_connect = enable_connect + self.force_connect = force_connect + self.timeout = timeout + self._cancelled = False + + def cancel(self): + """Cancel the device test.""" + self._cancelled = True + + def run(self): + """Run the device test.""" + if not READY_TO_TEST: + logger.error("Cannot run device test: dependencies not available.") + return + device_name = self.device_config.get("name", "") + self.signals.device_validation_started.emit(device_name) # Emit started signal + if self._cancelled: + logger.debug("Device test cancelled before start.") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{self.device_config.get('name')} was cancelled by user.", + ) + return + results = self.tester.run_with_list_output( + connect=self.enable_connect, + force_connect=self.force_connect, + timeout_per_device=self.timeout, + ) + if not results: + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "Results from OphydDevices StaticDeviceTest are empty.", + ) + return + try: + config_is_valid = int(results[0].config_is_valid) + connection_status = ( + int(results[0].success) if self.enable_connect else ConnectionStatus.UNKNOWN.value + ) + error_message = results[0].message or "" + self.signals.device_validated.emit( + self.device_config, config_is_valid, connection_status, error_message + ) + except Exception as e: + logger.error(f"Error reading results from device test: {e}") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"Error processing device test results: {e}", + ) + + +class ThreadPoolManager(QtCore.QObject): + """ + Manager wrapping QThreadPool to expose a queue for jobs. + It allows queued jobs to be cancelled if they have not yet started. + + Args: + max_workers (int): Maximum number of concurrent workers. + poll_interval_ms (int): Poll interval in milliseconds to check for new jobs. + """ + + validations_are_running = QtCore.Signal(bool) + device_validation_started = QtCore.Signal(str) + device_validated = QtCore.Signal(dict, int, int, str) + + def __init__(self, parent=None, max_workers: int = 4, poll_interval_ms: int = 100): + super().__init__(parent=parent) + self.pool = QtCore.QThreadPool(parent=parent) + self.pool.setMaxThreadCount(max_workers) + + self._queue = queue.Queue() + self._timer = QtCore.QTimer(parent=parent) + self._timer.timeout.connect(self._process_queue) + self.poll_interval_ms = poll_interval_ms + self._timer.setInterval(self.poll_interval_ms) + self._active_tests: dict[str, weakref.ReferenceType[DeviceTest]] = {} + + def start_polling(self): + """Start the polling timer.""" + if not self._timer.isActive(): + self._timer.start() + + def stop_polling(self): + """Stop the polling timer.""" + if self._timer.isActive(): + self._timer.stop() + + def _emit_device_validation_started(self, device_name: str): + """Emit device validation started signal.""" + self.device_validation_started.emit(device_name) + + def _emit_device_validated( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Emit device validated signal.""" + self.device_validated.emit(device_config, config_status, connection_status, error_message) + + def submit(self, device_name: str, device_test: DeviceTest): + """Queue a job for execution.""" + device_test.signals.device_validation_started.connect(self._emit_device_validation_started) + device_test.signals.device_validated.connect(self._emit_device_validated) + self._queue.put((device_name, device_test)) + + def clear_device_in_queue(self, device_name: str): + """Remove a specific device test from the queue.""" + if device_name in self._active_tests: + try: + ref = self._active_tests.pop(device_name) + obj = ref() + if obj and hasattr(obj, "cancel"): + obj.cancel() + obj.signals.device_validated.disconnect() + except KeyError: + logger.debug(f"Device {device_name} not found in active tests during cancellation.") + return + + with self._queue.mutex: + for name, runnable in self._queue.queue: + if name == device_name: # found the device to remove, discard it + runnable.cancel() + runnable.signals.device_validated.disconnect() + self._queue.queue = queue.deque( + item for item in self._queue.queue if item[0] != device_name + ) + break + + def clear_queue(self): + """Remove all queued (not yet started) jobs.""" + running = self.get_active_tests() + scheduled = self.get_scheduled_tests() + for device_name in running + scheduled: + self.clear_device_in_queue(device_name) + + def get_active_tests(self) -> list[str]: + """Return a list of currently active test device names.""" + return list(self._active_tests.keys()) + + def get_scheduled_tests(self) -> list[str]: + """Return a list of currently scheduled (queued) test device names.""" + with self._queue.mutex: + return [device_name for device_name, _ in list(self._queue.queue)] + + def _process_queue(self): + """Start new jobs if there is capacity. Runs with specified poll interval.""" + while not self._queue.empty() and len(self._active_tests) < self.pool.maxThreadCount(): + device_name, runnable = self._queue.get() + runnable.signals.device_validated.connect(self._on_task_finished) + self._active_tests[device_name] = weakref.ref(runnable) + self.pool.start(runnable) + self.validations_are_running.emit(len(self._active_tests) > 0) + + @SafeSlot(dict, int, int, str) + def _on_task_finished( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Handle task finished signal to update active thread count.""" + device_name = device_config.get("name", None) + if device_name: + self._active_tests.pop(device_name, None) + + +class LegendLabel(QtWidgets.QWidget): + """Wrapper widget for legend labels with icon and text for OphydValidation.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(18, 18), convert_to_pixmap=False + ) + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(8) + + # Config Status Legend + config_legend = QtWidgets.QLabel("Config Legend:") + layout.addWidget(config_legend, 0, 0) + for ii, status in enumerate( + [ConfigStatus.UNKNOWN, ConfigStatus.INVALID, ConfigStatus.VALID] + ): + icon = self._icons["config_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(False) + icon_widget.setToolTip(f"Device Configuration: {status.description()}") + layout.addWidget(icon_widget, 0, ii + 1) + + # Connection Status Legend + connection_status_legend = QtWidgets.QLabel("Connect Legend:") + layout.addWidget(connection_status_legend, 1, 0) + for ii, status in enumerate( + [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ConnectionStatus.CAN_CONNECT, + ConnectionStatus.CONNECTED, + ] + ): + icon = self._icons["connection_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(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 + + +class OphydValidation(BECWidget, QtWidgets.QWidget): + """ + Widget to manage and run ophyd device tests. + + Args: + parent (QWidget, optional): Parent widget. Defaults to None. + client (BECClient, optional): BEC client instance. Defaults to None. + hide_legend (bool, optional): Whether to hide the legend. Defaults to False. + """ + + RPC = False + + # ValidationResult: device_config, config_status, connection_status, error_message + validation_completed = QtCore.Signal(dict, int, int, str) + # ValidationResult: device_name, config_status, connection_status, error_message, formatted_error_message + item_clicked = QtCore.Signal(str, int, int, str, str) + # Signal to indicate if validations are currently running + validations_are_running = QtCore.Signal(bool) + # Signal to emit list of ValidationResults (device_config, config_status, connection_status, error_message) at once + multiple_validations_completed = QtCore.Signal(list) + + 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 + else: + self.thread_pool_manager = ThreadPoolManager(parent=self, max_workers=4) + self.thread_pool_manager.validations_are_running.connect(self._set_running_ophyd_tests) + self.thread_pool_manager.device_validated.connect(self._on_device_test_completed) + self.thread_pool_manager.device_validation_started.connect( + self._trigger_validation_started + ) + + self._validation_icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(32, 32), convert_to_pixmap=False + ) + + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(4) + self._colors = get_accent_colors() + + # Setup main UI + self.list_widget = self._create_list_widget_with_label("Running & Failed Validations") + if not hide_legend: + legend_widget = LegendLabel(parent=self) + 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() + # TODO consider removing as accent colors are the same across themes, or am I wrong? + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + + def _thread_pool_poll_loop(self): + """Start the thread pool polling loop.""" + if self.thread_pool_manager: + self.thread_pool_manager.start_polling() + + def _create_list_widget_with_label(self, label_text: str) -> BECList: + """Setup the running validations section.""" + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Section title + title_layout = QtWidgets.QHBoxLayout() + title_layout.setContentsMargins(0, 0, 0, 0) + title_label = QtWidgets.QLabel(label_text) + title_label.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + status_label = QtWidgets.QLabel("Config | Connect") + status_label.setStyleSheet("font-weight: bold; font-size: 9px; padding: 2px;") + title_layout.addWidget(title_label) + title_layout.addStretch(1) + title_layout.addWidget(status_label) + layout.addLayout(title_layout) + + # Separator line + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + layout.addWidget(separator) + + # List widget for running validations + list_w = BECList(parent=self) + list_w.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + list_w.itemClicked.connect(self._on_item_clicked) + list_w.currentItemChanged.connect(self._on_current_item_changed) + layout.addWidget(list_w) + + # Stop Running validation button + self._stop_validation_button = QtWidgets.QPushButton("Stop Running Validations") + self._stop_validation_button.clicked.connect(self.cancel_all_validations) + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + self._stop_validation_button.setVisible(False) + layout.addWidget(self._stop_validation_button) + self.validations_are_running.connect(self._stop_validation_button.setVisible) + self._main_layout.addWidget(widget) + + return list_w + + ########################## + ### Event Handlers + ########################## + + @SafeSlot(bool) + def _set_running_ophyd_tests(self, running: bool): + """Set the running ophyd tests state.""" + self.running_ophyd_tests = running + + @SafeSlot(QtWidgets.QListWidgetItem, QtWidgets.QListWidgetItem) + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle current item changed.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(current) + if widget: + self._emit_item_clicked(widget) + + @SafeSlot(QtWidgets.QListWidgetItem) + def _on_item_clicked(self, item: QtWidgets.QListWidgetItem): + """Handle click on running item.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(item) + if widget: + self._emit_item_clicked(widget) + + def _emit_item_clicked(self, widget: ValidationListItem): + format_error_msg = format_error_to_md( + widget.device_model.device_name, widget.device_model.validation_msg + ) + self.item_clicked.emit( + widget.device_model.device_name, + widget.device_model.config_status, + widget.device_model.connection_status, + widget.device_model.validation_msg, + format_error_msg, + ) + + ########################### + ### Properties + ########################### + + @SafeProperty(bool, notify=validations_are_running) + # pylint: disable=method-hidden + def running_ophyd_tests(self) -> bool: + """Indicates if validations are currently running.""" + return self._running_ophyd_tests + + @running_ophyd_tests.setter + def running_ophyd_tests(self, value: bool) -> None: + if self._running_ophyd_tests != value: + self._running_ophyd_tests = value + self.validations_are_running.emit(value) + + ########################### + ### Public Methods + ########################### + + @SafeSlot() + def clear_all(self): + """Clear all running and failed validations.""" + self.thread_pool_manager.clear_queue() + self.list_widget.clear_widgets() + + def get_device_configs(self) -> list[dict[str, Any]]: + """ + Get the current device configurations being tested. + + Returns: + list[dict[str, Any]]: List of device configurations. + """ + 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]], + added: bool, + 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.") + return + # Track all devices that are already in the running session from the + # config updates to avoid sending multiple single device validation signals. + # Sending successive single updates may affect the UI performance on the receiving end. + devices_already_in_session = [] + for cfg in device_configs: + device_name = cfg.get("name", None) + 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 + 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." + ) + devices_already_in_session.append( + ( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "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 + continue + # New device case, that is not in BEC session + if not self._device_already_exists(cfg.get("name")): + 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._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) + ) + + def cancel_validation(self, device_name: str) -> None: + """Cancel a running validation for a specific device. + + Args: + device_name (str): Name of the device to cancel validation for. + """ + if not READY_TO_TEST: + logger.error("Cannot cancel validation: dependencies not available.") + return + if self.thread_pool_manager: + self.thread_pool_manager.clear_device_in_queue(device_name) + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + self._on_device_test_completed( + widget.device_model.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{widget.device_model.device_name} was cancelled by user.", + ) + + def cancel_all_validations(self) -> None: + """Cancel all running validations.""" + if not READY_TO_TEST: + logger.error("Cannot cancel validations: dependencies not available.") + return + running = self.thread_pool_manager.get_active_tests() + scheduled = self.thread_pool_manager.get_scheduled_tests() + for device_name in running + scheduled: + self.cancel_validation(device_name) + + ################# + ### Private methods + ################# + + def _device_already_exists(self, device_name: str) -> bool: + 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, + ) -> None: + device_name = device_config.get("name") + # Check if device is in redis session with same config, if yes don't even bother testing.. + device_test_model = DeviceTestModel( + uuid=f"device_test_{device_name}_uuid_{uuid4()}", + device_name=device_name, + device_config=device_config, + ) + + widget = ValidationListItem( + parent=self, device_model=device_test_model, validation_icons=self._validation_icons + ) + 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) + + def _remove_device(self, device_name: str, force_remove: bool = False) -> None: + 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, + device_name: str, + device_config: dict[str, Any], + connect: bool, + force_connect: bool, + timeout: float, + ) -> None: + """Handle request to re-run validation for a device.""" + if not self._device_already_exists(device_name): + logger.debug( + f"Device with name {device_name} not found in OphydValidation, can't re-run." + ) + return + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget and not widget.is_running: + self.__delayed_submit_test(widget, connect, force_connect, timeout) + else: + logger.debug(f"Device {device_name} is already running validation, cannot re-run.") + + def _emit_device_in_redis_session(self, device_config: dict) -> None: + self.validation_completed.emit( + device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{device_config.get('name')} is OK. Already loaded in running session.", + ) + + def __delayed_submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Delayed submission of device test to ensure UI updates.""" + QtCore.QTimer.singleShot( + 0, lambda: self._submit_test(widget, connect, force_connect, timeout) + ) + + def _submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Submit a device test to the thread pool.""" + if not READY_TO_TEST or StaticDeviceTest is None: + logger.error("Cannot submit device test: dependencies not available.") + return + # Check if device is already in redis session with same config + if self._is_device_in_redis_session( + widget.device_model.device_name, widget.device_model.device_config + ): + logger.info( + f"Device {widget.device_model.device_name} already in running session with same config. " + "Skipping validation." + ) + self.validation_completed.emit( + widget.device_model.device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{widget.device_model.device_name} is OK. Already loaded in running session.", + ) + # 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: + self.thread_pool_manager.submit(widget.device_model.device_name, runnable) + + def _trigger_validation_started(self, device_name: str) -> None: + """Trigger validation started for a specific device.""" + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + widget.validation_started() + + def _on_device_test_completed( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ) -> None: + """Handle device test completion.""" + device_name = device_config.get("name") + if not self._device_already_exists(device_name): + logger.debug(f"Received test result for unknown device {device_name}. Ignoring.") + return + widget = self.list_widget.get_widget(device_name) + if widget: + widget.on_validation_finished( + validation_msg=error_message, + 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 + ) + + 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 + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import os + import random + + import bec_lib + from bec_lib.bec_yaml_loader import yaml_load + from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path + from bec_qthemes import apply_theme + + apply_theme("light") + # Main widget + wid = QtWidgets.QWidget() + w_layout = QtWidgets.QVBoxLayout(wid) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(0) + wid.setLayout(w_layout) + # Check if plugin is installed + + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + cfgs = [""] + cfgs.extend([os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")]) + if plugin_path: + print(f"Adding configs from plugin {plugin_name} at {plugin_path}") + cfg_base_path = os.path.join(plugin_path, plugin_name, "device_configs") + config_files = os.listdir(cfg_base_path) + cfgs.extend( + [os.path.join(cfg_base_path, f) for f in config_files if f.endswith((".yaml", ".yml"))] + ) + + combo_box_configs = QtWidgets.QComboBox() + combo_box_configs.addItems(cfgs) + combo_box_configs.setCurrentIndex(0) + + but_layout = QtWidgets.QHBoxLayout() + but_layout.addWidget(combo_box_configs) + button_reset = QtWidgets.QPushButton("Clear All") + but_layout.addWidget(button_reset) + button_clear_random = QtWidgets.QPushButton("Clear random amount") + but_layout.addWidget(button_clear_random) + w_layout.addLayout(but_layout) + + def _load_config(config_path: str): + current_config = device_manager_ophyd_test.get_device_configs() + device_manager_ophyd_test.change_device_configs(current_config, False) + if not config_path: # empty escape + return + try: + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, False, False, 2.0) + except Exception as e: + logger.error(f"Error loading config {config_path}: {e}") + + def _clear_random_entries(): + current_config = device_manager_ophyd_test.get_device_configs() + n_remove = random.randint(1, len(current_config)) + to_remove = random.sample(current_config, n_remove) + device_manager_ophyd_test.change_device_configs(to_remove, False) + + device_manager_ophyd_test = OphydValidation() + button_reset.clicked.connect(device_manager_ophyd_test.clear_all) + combo_box_configs.currentTextChanged.connect(_load_config) + button_clear_random.clicked.connect(_clear_random_entries) + + w_layout.addWidget(device_manager_ophyd_test) + + # Add text box for results + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + w_layout.addWidget(text_box) + + def _validation_callback( + device_name: str, + config_status: int, + connection_status: int, + error_message: str, + formatted_error_message: str, + ): # type: ignore + text_box.setMarkdown(formatted_error_message) + + device_manager_ophyd_test.item_clicked.connect(_validation_callback) + wid.resize(600, 1000) + wid.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py new file mode 100644 index 000000000..8c4cdf327 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py @@ -0,0 +1,171 @@ +import re +from enum import IntEnum +from functools import partial +from typing import Any, Literal + +from bec_qthemes import material_icon +from pydantic import BaseModel, Field +from qtpy import QtGui + +from bec_widgets.utils.colors import AccentColors + + +def format_error_to_md(device_name: str, raw_msg: str) -> str: + """ + Method to format a raw validation method into markdown for display. + The recognized patterns are: + - "'DEVICE_NAME' is OK. DETAIL" + - "ERROR: 'DEVICE_NAME' is not valid: DETAIL" + - "ERROR: 'DEVICE_NAME' is not connectable: DETAIL" + - "ERROR: 'DEVICE_NAME' failed: DETAIL" + If no patterns matched, the raw message is returned as a code block. + + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + + Returns: + str: The formatted markdown message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" + + # Regex to catch OK pattern + ok_pat = re.compile(r"(?P\S+)\s+is\s+OK\.?(?:\s*(?P.*))?$", re.IGNORECASE) + ok_match = ok_pat.search(raw_msg) + if ok_match: + device = ok_match.group("device") + detail = ok_match.group("detail").strip(".").strip() + return f"## Validation Success for {device}\n```\n{detail}\n```" + + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, + ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) + + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" + + return "\n\n---\n\n".join(blocks) + + +############################ +### Status Enums +############################ + + +class ConfigStatus(IntEnum): + """Validation status for device config validity. This includes the deviceClass check.""" + + INVALID = 0 + VALID = 1 + UNKNOWN = 2 + + def description(self) -> str: + """Get a human-readable description of the config status. + + Returns: + str: The description of the config status. + """ + descriptions = { + ConfigStatus.INVALID: "Invalid Configuration", + ConfigStatus.VALID: "Valid Configuration", + ConfigStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class ConnectionStatus(IntEnum): + """Connection status for device connectivity.""" + + CANNOT_CONNECT = 0 + CAN_CONNECT = 1 + CONNECTED = 2 + UNKNOWN = 3 + + def description(self) -> str: + """Get a human-readable description of the connection status. + + Returns: + str: The description of the connection status. + """ + descriptions = { + ConnectionStatus.CANNOT_CONNECT: "Cannot Connect", + ConnectionStatus.CAN_CONNECT: "Can Connect", + ConnectionStatus.CONNECTED: "Connected and Loaded", + ConnectionStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class DeviceTestModel(BaseModel): + """Model to hold device test parameters and results.""" + + uuid: str + device_name: str + device_config: dict[str, Any] + config_status: int = Field( + default=ConfigStatus.UNKNOWN.value, + description="Validation status of the device configuration.", + ) + connection_status: int = Field( + default=ConnectionStatus.UNKNOWN.value, description="Connection status of the device." + ) + validation_msg: str = Field(default="", description="Message from the last validation attempt.") + + +def get_validation_icons( + colors: AccentColors, icon_size: tuple[int, int], convert_to_pixmap: bool = False +) -> dict[Literal["config_status", "connection_status"], dict[int, QtGui.QPixmap | QtGui.QIcon]]: + """Get icons for validation statuses for ConfigStatus and ConnectionStatus. + + Args: + colors (AccentColors): The accent colors to use for the icons. + icon_size (tuple[int, int]): The size of the icons. + convert_to_pixmap (bool, optional): Whether to convert icons to pixmaps. Defaults to False. + + Returns: + dict: A dictionary with icons for config and connection statuses. + """ + material_icon_partial = partial( + material_icon, size=icon_size, convert_to_pixmap=convert_to_pixmap + ) + icons = { + "config_status": { + ConfigStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConfigStatus.VALID.value: material_icon_partial( + icon_name="check_circle", color=colors.success + ), + ConfigStatus.INVALID.value: material_icon_partial( + icon_name="error", color=colors.emergency + ), + }, + "connection_status": { + ConnectionStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConnectionStatus.CANNOT_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.emergency + ), + ConnectionStatus.CAN_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.success + ), + ConnectionStatus.CONNECTED.value: material_icon_partial( + icon_name="cast_connected", color=colors.success + ), + }, + } + return icons diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py new file mode 100644 index 000000000..f8566f2de --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py @@ -0,0 +1,374 @@ +"""Module with validation items and a validation button for device testing UI.""" + +from typing import Literal + +from bec_lib.logger import bec_logger +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.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + get_validation_icons, +) +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +logger = bec_logger.logger + + +class ValidationButton(QtWidgets.QPushButton): + """ + Validation button with flat style and disabled appearance. + + Args: + parent (QtWidgets.QWidget | None): Parent widget. + icon (QtGui.QIcon | None): Icon to display on the button. + """ + + def __init__( + self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None + ) -> None: + super().__init__(parent=parent) + if icon: + self.setIcon(icon) + self.setFlat(True) + self.setEnabled(True) + + def setEnabled(self, enabled: bool) -> None: + return super().setEnabled(enabled) + + +class ValidationDialog(QtWidgets.QDialog): + """ + Dialog to confirm re-validation with optional parameters. Once accepted, + the settings timeout, connect and force_connect can be retrieved through .result(). + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. + timeout (float, optional): The timeout for the validation. + connect (bool, optional): Whether to attempt connection during validation. + force_connect (bool, optional): Whether to force connection during validation. + """ + + def __init__( + self, parent=None, timeout: float = 5.0, connect: bool = False, force_connect: bool = False + ): + super().__init__(parent) + + self._result: tuple[float, bool, bool] = (timeout, connect, force_connect) + # Setup Dialog UI + self.setWindowTitle("Run Validation") + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + # label + self.label = QtWidgets.QLabel( + "Do you want to re-run validation with the following options?" + ) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + # Setup options (note timeout will be simplified to int) + option_layout = QtWidgets.QVBoxLayout() + option_layout.setSpacing(16) + option_layout.setContentsMargins(0, 0, 0, 0) + + # Timeout + timeout_layout = QtWidgets.QHBoxLayout() + label_timeout = QtWidgets.QLabel("Timeout(s):") + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setRange(1, 300) + self.timeout_spin.setValue(int(timeout)) + timeout_layout.addWidget(label_timeout) + timeout_layout.addWidget(self.timeout_spin) + + # Connect checkbox + self.connect_checkbox = QtWidgets.QCheckBox("Test Connection") + self.connect_checkbox.setChecked(connect) + + # Force Connect checkbox + self.force_connect_checkbox = QtWidgets.QCheckBox("Force Connect") + self.force_connect_checkbox.setChecked(force_connect) + if self.connect_checkbox.isChecked() is False: + self.force_connect_checkbox.setEnabled(False) + # Deactivated if connect is unchecked + self.connect_checkbox.stateChanged.connect(self.force_connect_checkbox.setEnabled) + + # Add widgets to layout + option_layout.addLayout(timeout_layout) + option_layout.addWidget(self.connect_checkbox) + option_layout.addWidget(self.force_connect_checkbox) + layout.addLayout(option_layout) + + # Dialog Buttons: equal size, stacked horizontally + self.button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + self.adjustSize() + + def accept(self): + """Process the dialog acceptance and store the result.""" + self._result = ( + float(self.timeout_spin.value()), + self.connect_checkbox.isChecked(), + self.force_connect_checkbox.isChecked(), + ) + super().accept() + + def result(self): + return self._result + + +class ValidationListItem(QtWidgets.QWidget): + """List item to display device test validation status.""" + + request_rerun_validation = QtCore.Signal(str, dict, bool, bool, float) + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + device_model: DeviceTestModel | None = None, + validation_icons: ( + dict[Literal["config_status", "connection_status"], dict[int, QtGui.QIcon]] | None + ) = None, + icon_size: tuple[int, int] = (32, 32), + ) -> None: + super().__init__(parent=parent) + if device_model is None: + logger.debug("No device config provided to ValidationListItem.") + return + self.device_model: DeviceTestModel = device_model + self.is_running: bool = False + self._colors = get_accent_colors() + self._icon_size = icon_size + self._validation_icons = validation_icons or get_validation_icons( + colors=self._colors, icon_size=self._icon_size, convert_to_pixmap=False + ) + + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self._setup_ui() + + ###################### + ### UI Setup Methods + ###################### + + def _setup_ui(self) -> None: + """Setup the UI elements of the widget.""" + # Device Name Label + label = QtWidgets.QLabel(self.device_model.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + + # Spinner + self._spinner = SpinnerWidget() + self._spinner.speed = 80 + self._spinner.setFixedSize(self._icon_size[0] // 1.5, self._icon_size[1] // 1.5) + self._spinner.setVisible(False) + + # Add to button layout + button_layout.addWidget(self._spinner) + + # Config Status Icon + self.status_button = ValidationButton( + icon=self._validation_icons["config_status"][self.device_model.config_status] + ) + self.status_button.setToolTip("Configuration Status") + self.status_button.clicked.connect(self._on_status_button_clicked) + button_layout.addWidget(self.status_button) + + # Connection Status Icon + self.connection_button = ValidationButton( + icon=self._validation_icons["connection_status"][self.device_model.connection_status] + ) + self.connection_button.setToolTip("Connection Status") + self.connection_button.clicked.connect(self._on_connection_button_clicked) + button_layout.addWidget(self.connection_button) + self.main_layout.addLayout(button_layout) + + ####################### + ### Event Handlers + ####################### + + def _on_status_button_clicked(self) -> None: + """Handle status button click event.""" + timeout, connect, force_connect = 5, False, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + def _on_connection_button_clicked(self) -> None: + """Handle connection button click event.""" + timeout, connect, force_connect = 5, True, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + ######################### + ### Helper Methods + ######################### + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + def _create_validation_dialog_box( + self, timeout: float, connect: bool, force_connect: bool + ) -> QtWidgets.QDialog: + """Create a dialog box to confirm re-validation.""" + return ValidationDialog( + parent=self, timeout=timeout, connect=connect, force_connect=force_connect + ) + + def _update_validation_status( + self, validation_msg: str, config_status: int, connection_status: int + ): + """ + Update the validation status icons and message. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + # Update device config model + self.device_model.validation_msg = validation_msg + self.device_model.config_status = ConfigStatus(config_status).value + self.device_model.connection_status = ConnectionStatus(connection_status).value + + # Update icons + self.status_button.setIcon( + self._validation_icons["config_status"][self.device_model.config_status] + ) + self.connection_button.setIcon( + self._validation_icons["connection_status"][self.device_model.connection_status] + ) + + ########################## + ### Public Methods + ########################## + + @SafeSlot(str, int, int) + def on_validation_finished( + self, validation_msg: str, config_status: int, connection_status: int + ): + """Handle validation finished event. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.is_running = False + self._stop_spinner() + self._update_validation_status(validation_msg, config_status, connection_status) + + # Enable/disable buttons based on status + config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID] + self.status_button.setEnabled(config_but_en) + connect_but_en = connection_status in [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ] + self.connection_button.setEnabled(connect_but_en) + + @SafeSlot() + def validation_scheduled(self): + """Handle validation scheduled event.""" + self._update_validation_status( + "Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + self.status_button.setEnabled(False) + self.connection_button.setEnabled(False) + self._spinner.setVisible(True) + + @SafeSlot() + def validation_started(self): + """Start validation process.""" + self.is_running = True + self._start_spinner() + self._update_validation_status( + "Validation running...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + + @SafeSlot() + def start_validation(self): + """Start validation process.""" + self.validation_scheduled() + self.validation_started() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("dark") + w = QtWidgets.QWidget() + l = QtWidgets.QVBoxLayout(w) + + # Example device model + device_model = DeviceTestModel( + uuid="1234", + device_name="Test Device", + device_config={"param1": "value1"}, + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, + validation_msg="Initial validation failed.", + ) + + # Create validation list item + validation_item = ValidationListItem(parent=w, device_model=device_model) + l.addWidget(validation_item) + + but = QtWidgets.QPushButton("Start Validation") + but2 = QtWidgets.QPushButton("Finish Validation") + but.clicked.connect(validation_item.start_validation) + but2.clicked.connect( + lambda: validation_item.on_validation_finished( + "Validation successful.", + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ) + ) + l.addWidget(but) + l.addWidget(but2) + + def _print_callback(name, cfg, conn, force, to): + print( + f"Re-run validation requested for dev {name} for config {cfg} with timeout={to}, connect={conn}, force={force}" + ) + + validation_item.request_rerun_validation.connect(_print_callback) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/device_manager.py b/bec_widgets/widgets/control/device_manager/device_manager.py deleted file mode 100644 index 04178cae5..000000000 --- a/bec_widgets/widgets/control/device_manager/device_manager.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -This module provides an implementation for the device config view. -The widget is the entry point for users to edit device configurations. -""" diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 043250e3c..1a633ec3a 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -20,7 +20,7 @@ from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox @@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget): Widget to submit new scans to the queue. """ - USER_ACCESS = ["remove", "screenshot"] + USER_ACCESS = ["attach", "detach", "screenshot"] PLUGIN = True ICON_NAME = "tune" ARG_BOX_POSITION: int = 2 @@ -91,6 +91,11 @@ def __init__( self._scan_metadata: dict | None = None self._metadata_form = ScanMetadata(parent=self) + self._hide_arg_box = False + self._hide_kwarg_boxes = False + self._hide_scan_control_buttons = False + self._hide_metadata = False + self._hide_scan_selection_combobox = False # Create and set main layout self._init_UI() @@ -120,7 +125,7 @@ def _init_UI(self): # Label to reload the last scan parameters within scan selection group box self.toggle_layout = QHBoxLayout() self.toggle_layout.addSpacerItem( - QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed) + QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) ) self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group) self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False) @@ -128,21 +133,20 @@ def _init_UI(self): self.toggle_layout.addWidget(self.last_scan_label) self.toggle_layout.addWidget(self.toggle) self.scan_selection_group.layout().addLayout(self.toggle_layout) - self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_selection_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.layout.addWidget(self.scan_selection_group) # Scan control (Run/Stop) buttons self.scan_control_group = QWidget(self) - self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_control_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.button_layout = QHBoxLayout(self.scan_control_group) self.button_run_scan = QPushButton("Start", self.scan_control_group) - self.button_run_scan.setStyleSheet( - f"background-color: {palette.success.name()}; color: white" - ) + self.button_run_scan.setProperty("variant", "success") self.button_stop_scan = StopButton(parent=self.scan_control_group) - self.button_stop_scan.setStyleSheet( - f"background-color: {palette.emergency.name()}; color: white" - ) self.button_layout.addWidget(self.button_run_scan) self.button_layout.addWidget(self.button_stop_scan) self.layout.addWidget(self.scan_control_group) @@ -267,9 +271,7 @@ def set_current_scan(self, scan_name: str): @SafeProperty(bool) def hide_arg_box(self): """Property to hide the argument box.""" - if self.arg_box is None: - return True - return not self.arg_box.isVisible() + return self._hide_arg_box @hide_arg_box.setter def hide_arg_box(self, hide: bool): @@ -278,18 +280,14 @@ def hide_arg_box(self, hide: bool): Args: hide(bool): Hide or show the argument box. """ + self._hide_arg_box = hide if self.arg_box is not None: self.arg_box.setVisible(not hide) @SafeProperty(bool) def hide_kwarg_boxes(self): """Property to hide the keyword argument boxes.""" - if len(self.kwarg_boxes) == 0: - return True - - for box in self.kwarg_boxes: - if box is not None: - return not box.isVisible() + return self._hide_kwarg_boxes @hide_kwarg_boxes.setter def hide_kwarg_boxes(self, hide: bool): @@ -298,6 +296,7 @@ def hide_kwarg_boxes(self, hide: bool): Args: hide(bool): Hide or show the keyword argument boxes. """ + self._hide_kwarg_boxes = hide if len(self.kwarg_boxes) > 0: for box in self.kwarg_boxes: box.setVisible(not hide) @@ -305,7 +304,7 @@ def hide_kwarg_boxes(self, hide: bool): @SafeProperty(bool) def hide_scan_control_buttons(self): """Property to hide the scan control buttons.""" - return not self.button_run_scan.isVisible() + return self._hide_scan_control_buttons @hide_scan_control_buttons.setter def hide_scan_control_buttons(self, hide: bool): @@ -314,12 +313,13 @@ def hide_scan_control_buttons(self, hide: bool): Args: hide(bool): Hide or show the scan control buttons. """ + self._hide_scan_control_buttons = hide self.show_scan_control_buttons(not hide) @SafeProperty(bool) def hide_metadata(self): """Property to hide the metadata form.""" - return not self._metadata_form.isVisible() + return self._hide_metadata @hide_metadata.setter def hide_metadata(self, hide: bool): @@ -328,6 +328,7 @@ def hide_metadata(self, hide: bool): Args: hide(bool): Hide or show the metadata form. """ + self._hide_metadata = hide self._metadata_form.setVisible(not hide) @SafeProperty(bool) @@ -347,12 +348,13 @@ def hide_optional_metadata(self, hide: bool): @SafeSlot(bool) def show_scan_control_buttons(self, show: bool): """Shows or hides the scan control buttons.""" + self._hide_scan_control_buttons = not show self.scan_control_group.setVisible(show) @SafeProperty(bool) def hide_scan_selection_combobox(self): """Property to hide the scan selection combobox.""" - return not self.comboBox_scan_selection.isVisible() + return self._hide_scan_selection_combobox @hide_scan_selection_combobox.setter def hide_scan_selection_combobox(self, hide: bool): @@ -361,11 +363,13 @@ def hide_scan_selection_combobox(self, hide: bool): Args: hide(bool): Hide or show the scan selection combobox. """ + self._hide_scan_selection_combobox = hide self.show_scan_selection_combobox(not hide) @SafeSlot(bool) def show_scan_selection_combobox(self, show: bool): """Shows or hides the scan selection combobox.""" + self._hide_scan_selection_combobox = not show self.scan_selection_group.setVisible(show) @SafeSlot(str) @@ -417,9 +421,10 @@ def add_kwargs_boxes(self, groups: list): position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0) for group in groups: box = ScanGroupBox(box_type="kwargs", config=group) - box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.layout.insertWidget(position + len(self.kwarg_boxes), box) self.kwarg_boxes.append(box) + box.setVisible(not self._hide_kwarg_boxes) def add_arg_group(self, group: dict): """ @@ -429,9 +434,10 @@ def add_arg_group(self, group: dict): """ self.arg_box = ScanGroupBox(box_type="args", config=group) self.arg_box.device_selected.connect(self.emit_device_selected) - self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box) + self.arg_box.setVisible(not self._hide_arg_box) @SafeSlot(str) def emit_device_selected(self, dev_names): @@ -472,6 +478,8 @@ def get_scan_parameters(self, bec_object: bool = True): for box in self.kwarg_boxes: box_kwargs = box.get_parameters(bec_object) kwargs.update(box_kwargs) + if self._scan_metadata is not None: + kwargs["metadata"] = self._scan_metadata return args, kwargs def restore_scan_parameters(self, scan_name: str): @@ -524,7 +532,6 @@ def update_scan_metadata(self, md: dict | None): def run_scan(self): """Starts the selected scan with the given parameters.""" args, kwargs = self.get_scan_parameters() - kwargs["metadata"] = self._scan_metadata self.scan_args.emit(args) scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText()) if callable(scan_function): @@ -547,12 +554,10 @@ def cleanup(self): # Application example if __name__ == "__main__": # pragma: no cover - from bec_widgets.utils.colors import set_theme - app = QApplication([]) scan_control = ScanControl() - set_theme("auto") + apply_theme("dark") window = scan_control window.show() app.exec() diff --git a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py index c9892ba3c..5931d6726 100644 --- a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +++ b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py @@ -175,10 +175,10 @@ def _validate_dap_model(self, model: str | None) -> bool: # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py index 05a5623c4..68870b605 100644 --- a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py @@ -65,6 +65,9 @@ def __init__( self._move_buttons = [] self._accent_colors = get_accent_colors() self.action_buttons = {} + self._hide_curve_selection = False + self._hide_summary = False + self._hide_parameters = False @property def enable_actions(self) -> bool: @@ -108,7 +111,7 @@ def always_show_latest(self, show: bool): @SafeProperty(bool) def hide_curve_selection(self): """SafeProperty for showing the curve selection.""" - return not self.ui.group_curve_selection.isVisible() + return self._hide_curve_selection @hide_curve_selection.setter def hide_curve_selection(self, show: bool): @@ -117,12 +120,13 @@ def hide_curve_selection(self, show: bool): Args: show (bool): Whether to show the curve selection. """ + self._hide_curve_selection = show self.ui.group_curve_selection.setVisible(not show) @SafeProperty(bool) def hide_summary(self) -> bool: """SafeProperty for showing the summary.""" - return not self.ui.group_summary.isVisible() + return self._hide_summary @hide_summary.setter def hide_summary(self, show: bool): @@ -131,12 +135,13 @@ def hide_summary(self, show: bool): Args: show (bool): Whether to show the summary. """ + self._hide_summary = show self.ui.group_summary.setVisible(not show) @SafeProperty(bool) def hide_parameters(self) -> bool: """SafeProperty for showing the parameters.""" - return not self.ui.group_parameters.isVisible() + return self._hide_parameters @hide_parameters.setter def hide_parameters(self, show: bool): @@ -145,6 +150,7 @@ def hide_parameters(self, show: bool): Args: show (bool): Whether to show the parameters. """ + self._hide_parameters = show self.ui.group_parameters.setVisible(not show) @property diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 3efe3887f..a9fb0644f 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -249,10 +249,10 @@ def autoscale(self, autoscale: bool): if __name__ == "__main__": # pragma: no cover - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) window.show() diff --git a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py index 24234db64..e53226898 100644 --- a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py +++ b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py @@ -2,6 +2,7 @@ from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.manager import QtKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget +from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QMainWindow @@ -9,10 +10,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: def __init__(self, inprocess: bool = False): super().__init__() - self.inprocess = None - self.client = None + self.inprocess = inprocess + self.ipyclient = None - self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess) + self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess) self.set_default_style("linux") self._init_bec() @@ -35,14 +36,13 @@ def _init_bec(self): self._init_bec_kernel() def _init_bec_inprocess(self): - self.client = BECIPythonClient() - self.client.start() - + self.ipyclient = BECIPythonClient() + self.ipyclient.start() self.kernel_manager.kernel.shell.push( { - "bec": self.client, - "dev": self.client.device_manager.devices, - "scans": self.client.scans, + "bec": self.ipyclient, + "dev": self.ipyclient.device_manager.devices, + "scans": self.ipyclient.scans, } ) @@ -57,20 +57,47 @@ def _init_bec_kernel(self): """ ) + def _cleanup_bec(self): + if getattr(self, "ipyclient", None) is not None and self.inprocess is True: + self.ipyclient.shutdown() + self.ipyclient = None + def shutdown_kernel(self): + """ + Shutdown the Jupyter kernel and clean up resources. + """ + self._cleanup_bec() self.kernel_client.stop_channels() self.kernel_manager.shutdown_kernel() + self.kernel_client = None + self.kernel_manager = None def closeEvent(self, event): self.shutdown_kernel() + event.accept() + super().closeEvent(event) + + +class JupyterConsoleWindow(QMainWindow): # pragma: no cover: + def __init__(self, inprocess: bool = True, parent=None): + super().__init__(parent) + self.console = BECJupyterConsole(inprocess=inprocess) + self.setCentralWidget(self.console) + self.setAttribute(Qt.WA_DeleteOnClose, True) + + def closeEvent(self, event): + # Explicitly close the console so its own closeEvent runs + if getattr(self, "console", None) is not None: + self.console.close() + event.accept() + super().closeEvent(event) if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - win = QMainWindow() - win.setCentralWidget(BECJupyterConsole(True)) + win = JupyterConsoleWindow(inprocess=True) win.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py new file mode 100644 index 000000000..663a90f78 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import os +import pathlib +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 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.qt_ads import CDockAreaWidget, CDockWidget +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class MonacoDock(DockAreaWidget): + """ + MonacoDock is a dock widget that contains Monaco editor instances. + It is used to manage multiple Monaco editors in a dockable interface. + """ + + focused_editor = Signal(object) # Emitted when the focused editor changes + save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled + signature_help = Signal(str) # Emitted when signature help is requested + macro_file_updated = Signal(str) # Emitted when a macro file is saved + + def __init__(self, parent=None, **kwargs): + super().__init__( + parent=parent, + variant="compact", + title="Monaco Editors", + default_add_direction="top", + **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 + + def _create_editor_widget(self) -> MonacoWidget: + """Create a configured Monaco editor widget.""" + init_lsp = len(self.dock_manager.dockWidgets()) == 0 + widget = MonacoWidget(self, init_lsp=init_lsp) + widget.save_enabled.connect(self.save_enabled.emit) + widget.editor.signature_help_triggered.connect(self._on_signature_change) + return widget + + @property + def last_focused_editor(self) -> CDockWidget | None: + """ + Get the last focused editor. + """ + return self._last_focused_editor + + @last_focused_editor.setter + def last_focused_editor(self, editor: CDockWidget | None): + self._last_focused_editor = editor + self.focused_editor.emit(editor) + + def _on_last_focused_editor_changed(self, editor: CDockWidget | None): + if editor is None: + self.save_enabled.emit(False) + return + + widget = cast(MonacoWidget, editor.widget()) + if widget.modified: + logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}") + self.save_enabled.emit(widget.modified) + + 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() + + # Remove existing modification indicator (dot and space) + if current_title.startswith("• "): + base_title = current_title[2:] # Remove "• " + else: + base_title = current_title + + # Add or remove the modification indicator + if modified: + new_title = f"• {base_title}" + else: + new_title = base_title + + dock.setWindowTitle(new_title) + + def _on_signature_change(self, signature: dict): + signatures = signature.get("signatures", []) + if not signatures: + self.signature_help.emit("") + 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 + markdown = f"```python\n{label}\n```\n\n{documentation}" + self.signature_help.emit(markdown) + + def _on_focus_event(self, old_widget, new_widget) -> None: + # Track focus events for the dock widget + widget = new_widget.widget() + if isinstance(widget, MonacoWidget): + self.last_focused_editor = new_widget + + def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): + # Cast widget to MonacoWidget since we know that's what it is + monaco_widget = cast(MonacoWidget, widget) + + # Check if we have unsaved changes + if monaco_widget.modified: + # Prompt the user to save changes + response = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Do you want to save them?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if response == QMessageBox.StandardButton.Yes: + self.save_file(monaco_widget) + elif response == QMessageBox.StandardButton.Cancel: + return + + # Count all editor docks managed by this dock manager + total = len(self.dock_manager.dockWidgets()) + if total <= 1: + # 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) + 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 + monaco_widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + if self.last_focused_editor is dock: + self.last_focused_editor = None + # After topology changes, make sure single-tab areas get a plus button + QTimer.singleShot(0, self._scan_and_fix_areas) + + def reset_widget(self, 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"] = "" + + def _ensure_area_plus(self, area): + if area is None: + return + # Only add once per area + if getattr(area, "_monaco_plus_btn", None) is not None: + return + # If the area has exactly one tab, inject a + button next to the tab bar + try: + tabbar = area.titleBar().tabBar() + count = tabbar.count() if hasattr(tabbar, "count") else 1 + except Exception: + count = 1 + if count >= 1: + plus_btn = QToolButton(area) + plus_btn.setText("+") + plus_btn.setToolTip("New Monaco Editor") + plus_btn.setAutoRaise(True) + tb = area.titleBar() + idx = tb.indexOf(tb.tabBar()) + tb.insertWidget(idx + 1, plus_btn) + plus_btn.clicked.connect(lambda: self.add_editor(area)) + # pylint: disable=protected-access + area._monaco_plus_btn = plus_btn + + 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: + """ + Add a new Monaco editor dock to the specified area. + + Args: + area(Any | None): The area to add the editor to. If None, adds to the main area. + title(str | None): The title of the editor tab. If None, a default title is used. + tooltip(str | None): The tooltip for the editor tab. If None, no tooltip is set. + + Returns: + CDockWidget: The created dock widget containing the Monaco editor. + """ + widget = self._create_editor_widget() + existing_count = len(self.dock_manager.dockWidgets()) + default_title = title or f"Untitled_{existing_count + 1}" + + tab_target: CDockWidget | None = None + if isinstance(area, CDockAreaWidget): + tab_target = area.currentDockWidget() + if tab_target is None: + docks = area.dockWidgets() + tab_target = docks[0] if docks else None + + dock = self.new( + widget, + closable=True, + floatable=False, + movable=True, + tab_with=tab_target, + return_dock=True, + on_close=self._on_editor_close_requested, + title_buttons={"float": False}, + where="right", + ) + dock.setWindowTitle(default_title) + if tooltip is not None: + dock.setTabToolTip(tooltip) + + widget.save_enabled.connect( + lambda modified, target=dock: self._update_tab_title_for_modification(target, modified) + ) + + area_widget = dock.dockAreaWidget() + if area_widget is not None: + self._ensure_area_plus(area_widget) + + 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: + """ + 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) + if dock is not None: + dock.setAsCurrentTab() + self.last_focused_editor = dock + return + + file = os.path.basename(file_name) + # If the current editor is empty, we reuse it + + # For now, the dock manager is only for the editor docks. We can therefore safely assume + # that all docks are editor docks. + dock_area = self.dock_manager.dockArea(0) + if not dock_area: + return + + editor_dock = dock_area.currentDockWidget() + if not editor_dock: + return + + editor_widget = editor_dock.widget() if editor_dock else None + if editor_widget: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + if editor_widget.current_file is None and editor_widget.get_text() == "": + editor_dock.setWindowTitle(file) + editor_dock.setTabToolTip(file_name) + editor_widget.open_file(file_name) + editor_widget.metadata["scope"] = scope + self.last_focused_editor = editor_dock + return + + # File is not open, create a new editor + 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 + editor_dock.setAsCurrentTab() + self.last_focused_editor = editor_dock + + def save_file( + self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True + ) -> None: + """ + Save the currently focused file. + + Args: + widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used. + force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved. + format_on_save (bool): If True, format the code before saving if it's a Python file. + """ + if widget is None: + widget = self.last_focused_editor.widget() if self.last_focused_editor else None + if not widget: + return + if "macros" in widget.metadata.get("scope", ""): + if not self._validate_macros(widget.get_text()): + return + + if widget.current_file and not force_save_as: + if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": + widget.format() + + with open(widget.current_file, "w", encoding="utf-8") as f: + f.write(widget.get_text()) + + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(widget.current_file) + + # pylint: disable=protected-access + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + return + + # Save as option + save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") + + if not save_file or not save_file[0]: + return + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() + + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text + + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) + + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) + + logger.debug(f"Save file called, last focused editor: {self.last_focused_editor}") + + def _validate_macros(self, source: str) -> bool: + # pylint: disable=protected-access + # Ensure the macro does not contain executable code before saving + exec_code, line_number = has_executable_code(source) + if exec_code: + if line_number is None: + msg = "The macro contains executable code. Please remove it before saving." + else: + msg = f"The macro contains executable code on line {line_number}. Please remove it before saving." + QMessageBox.warning(self, "Save Error", msg) + return False + return True + + def _update_macros(self, widget: MonacoWidget): + # pylint: disable=protected-access + if not widget.current_file: + return + # Check which macros have changed and broadcast the change + macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file) + existing_macros = self.client.macros._update_handler.get_existing_macros( + widget.current_file + ) + + removed_macros = set(existing_macros.keys()) - set(macros.keys()) + added_macros = set(macros.keys()) - set(existing_macros.keys()) + for name, info in macros.items(): + if name in added_macros: + self.client.macros._update_handler.broadcast( + action="add", name=name, file_path=widget.current_file + ) + if ( + name in existing_macros + and info.get("source", "") != existing_macros[name]["source"] + ): + self.client.macros._update_handler.broadcast( + action="reload", name=name, file_path=widget.current_file + ) + for name in removed_macros: + self.client.macros._update_handler.broadcast(action="remove", name=name) + + def set_vim_mode(self, enabled: bool): + """ + Set Vim mode for all editor widgets. + + Args: + enabled (bool): Whether to enable or disable Vim mode. + """ + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + editor_widget.set_vim_mode_enabled(enabled) + + def _get_open_files(self) -> list[str]: + open_files = [] + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file is not None: + open_files.append(editor_widget.current_file) + return open_files + + def _get_editor_dock(self, file_name: str) -> CDockWidget | None: + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file == file_name: + return widget + return None + + def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool: + """ + Set a specific file's editor to read-only mode. + + Args: + file_name (str): The file path to set read-only + read_only (bool): Whether to set read-only mode (default: True) + + Returns: + bool: True if the file was found and read-only was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + editor_widget.set_readonly(read_only) + return True + return False + + def set_file_icon(self, file_name: str, icon) -> bool: + """ + Set an icon for a specific file's tab. + + Args: + file_name (str): The file path to set icon for + icon: The QIcon to set on the tab + + Returns: + bool: True if the file was found and icon was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_dock.setIcon(icon) + return True + return False + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + _dock = MonacoDock() + _dock.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 076005309..b1ec71e68 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,11 +1,24 @@ -from typing import Literal +from __future__ import annotations +import os +import traceback +from typing import TYPE_CHECKING, Literal + +import black +import isort import qtmonaco +from bec_lib.logger import bec_logger from qtpy.QtCore import Signal -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot + +if TYPE_CHECKING: + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +logger = bec_logger.logger class MonacoWidget(BECWidget, QWidget): @@ -14,6 +27,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +35,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -32,9 +47,14 @@ class MonacoWidget(BECWidget, QWidget): "set_vim_mode_enabled", "set_lsp_header", "get_lsp_header", + "attach", + "detach", + "screenshot", ] - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs + ): super().__init__( parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs ) @@ -44,7 +64,30 @@ def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs) layout.addWidget(self.editor) self.setLayout(layout) self.editor.text_changed.connect(self.text_changed.emit) + self.editor.text_changed.connect(self._check_save_status) self.editor.initialized.connect(self.apply_theme) + self.editor.initialized.connect(self._setup_context_menu) + self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) + self._current_file = None + self._original_content = "" + self.metadata = {} + if init_lsp: + self.editor.update_workspace_configuration( + { + "pylsp": { + "plugins": { + "pylsp-bec": {"service_config": self.client._service_config.config} + } + } + } + ) + + @property + def current_file(self): + """ + Get the current file being edited. + """ + return self._current_file def apply_theme(self, theme: str | None = None) -> None: """ @@ -58,14 +101,22 @@ def apply_theme(self, theme: str | None = None) -> None: editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str) -> None: + def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ - self.editor.set_text(text) + 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: """ @@ -73,6 +124,32 @@ def get_text(self) -> str: """ return self.editor.get_text() + def format(self) -> None: + """ + Format the current text in the Monaco editor. + """ + if not self.editor: + return + try: + content = self.get_text() + try: + formatted_content = black.format_str(content, mode=black.Mode(line_length=100)) + except Exception: # black.NothingChanged or other formatting exceptions + formatted_content = content + + config = isort.Config( + profile="black", + line_length=100, + multi_line_output=3, + include_trailing_comma=False, + known_first_party=["bec_widgets"], + ) + formatted_content = isort.code(formatted_content, config=config) + self.set_text(formatted_content, file_name=self.current_file) + except Exception: + content = traceback.format_exc() + logger.info(content) + def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: """ Insert text at the current cursor position or at a specified line and column. @@ -93,6 +170,32 @@ def delete_line(self, line: int | None = None) -> None: """ self.editor.delete_line(line) + def open_file(self, file_name: str) -> None: + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + if not os.path.exists(file_name): + raise FileNotFoundError(f"The specified file does not exist: {file_name}") + + with open(file_name, "r", encoding="utf-8") as file: + content = file.read() + self.set_text(content, file_name=file_name, reset=True) + + @property + def modified(self) -> bool: + """ + Check if the editor content has been modified. + """ + return self._original_content != self.get_text() + + @SafeSlot(str) + def _check_save_status(self, _text: str) -> None: + self.save_enabled.emit(self.modified) + def set_cursor( self, line: int, @@ -210,6 +313,46 @@ def get_lsp_header(self) -> str: """ return self.editor.get_lsp_header() + def _setup_context_menu(self): + """Setup custom context menu actions for the Monaco editor.""" + # Add the "Insert Scan" action to the context menu + self.editor.add_action("insert_scan", "Insert Scan", "python") + # Add the "Format Code" action to the context menu + self.editor.add_action("format_code", "Format Code", "python") + + def _handle_context_menu_action(self, action_id: str): + """Handle context menu action triggers.""" + if action_id == "insert_scan": + self._show_scan_control_dialog() + elif action_id == "format_code": + self._format_code() + + def _show_scan_control_dialog(self): + """Show the scan control dialog and insert the generated scan code.""" + # Import here to avoid circular imports + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + + dialog = ScanControlDialog(self, client=self.client) + self._run_dialog_and_insert_code(dialog) + + def _run_dialog_and_insert_code(self, dialog: ScanControlDialog): + """ + Run the dialog and insert the generated scan code if accepted. + It is a separate method to allow easier testing. + + Args: + dialog (ScanControlDialog): The scan control dialog instance. + """ + if dialog.exec_() == QDialog.DialogCode.Accepted: + scan_code = dialog.get_scan_code() + if scan_code: + # Insert the scan code at the current cursor position + self.insert_text(scan_code) + + def _format_code(self): + """Format the current code in the editor.""" + self.format() + if __name__ == "__main__": # pragma: no cover qapp = QApplication([]) @@ -231,7 +374,7 @@ def get_lsp_header(self) -> str: scans: Scans ####################################### -########## User Script ##################### +########## User Script ################ ####################################### # This is a comment diff --git a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py new file mode 100644 index 000000000..f77e62c55 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -0,0 +1,145 @@ +""" +Scan Control Dialog for Monaco Editor + +This module provides a dialog wrapper around the ScanControl widget, +allowing users to configure and generate scan code that can be inserted +into the Monaco editor. +""" + +from bec_lib.device import Device +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout + +from bec_widgets.widgets.control.scan_control import ScanControl + +logger = bec_logger.logger + + +class ScanControlDialog(QDialog): + """ + Dialog window containing the ScanControl widget for generating scan code. + + This dialog allows users to configure scan parameters and generates + Python code that can be inserted into the Monaco editor. + """ + + def __init__(self, parent=None, client=None): + super().__init__(parent) + self.setWindowTitle("Insert Scan") + + # Store the client for passing to ScanControl + self.client = client + self._scan_code = "" + + self._setup_ui() + + def sizeHint(self) -> QSize: + return QSize(600, 800) + + def _setup_ui(self): + """Setup the dialog UI with ScanControl widget and buttons.""" + layout = QVBoxLayout(self) + + # Create the scan control widget + self.scan_control = ScanControl(parent=self, client=self.client) + self.scan_control.show_scan_control_buttons(False) + layout.addWidget(self.scan_control) + + # Create dialog buttons + button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self) + + # Create custom buttons with appropriate text + insert_button = QPushButton("Insert") + cancel_button = QPushButton("Cancel") + + button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) + + # Connect button signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def _generate_scan_code(self): + """Generate Python code for the configured scan.""" + try: + # Get scan parameters from the scan control widget + args, kwargs = self.scan_control.get_scan_parameters() + scan_name = self.scan_control.current_scan + + if not scan_name: + self._scan_code = "" + return + + # Process arguments and add device prefix where needed + processed_args = self._process_arguments_for_code_generation(args) + processed_kwargs = self._process_kwargs_for_code_generation(kwargs) + + # Generate the Python code string + code_parts = [] + + # Process arguments and keyword arguments + all_args = [] + + # Add positional arguments + if processed_args: + all_args.extend(processed_args) + + # Add keyword arguments (excluding metadata) + if processed_kwargs: + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items()] + all_args.extend(kwargs_strs) + + # Join all arguments and create the scan call + args_str = ", ".join(all_args) + if args_str: + code_parts.append(f"scans.{scan_name}({args_str})") + else: + code_parts.append(f"scans.{scan_name}()") + + self._scan_code = "\n".join(code_parts) + + except Exception as e: + logger.error(f"Error generating scan code: {e}") + self._scan_code = f"# Error generating scan code: {e}\n" + + def _process_arguments_for_code_generation(self, args): + """Process arguments to add device prefixes and proper formatting.""" + return [self._format_value_for_code(arg) for arg in args] + + def _process_kwargs_for_code_generation(self, kwargs): + """Process keyword arguments to add device prefixes and proper formatting.""" + return {key: self._format_value_for_code(value) for key, value in kwargs.items()} + + def _format_value_for_code(self, value): + """Format a single value for code generation.""" + if isinstance(value, Device): + return f"dev.{value.name}" + return repr(value) + + def get_scan_code(self) -> str: + """ + Get the generated scan code. + + Returns: + str: The Python code for the configured scan. + """ + return self._scan_code + + def accept(self): + """Override accept to generate code before closing.""" + self._generate_scan_code() + super().accept() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = ScanControlDialog() + dialog.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 3cee7b069..5df774f02 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -49,6 +49,7 @@ def __init__( self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._additional_metadata.data_changed.connect(self.validate_form) + self._hide_optional_metadata = False super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) @@ -63,7 +64,7 @@ def update_with_new_scan(self, scan_name: str): @SafeProperty(bool) def hide_optional_metadata(self): # type: ignore """Property to hide the optional metadata table.""" - return not self._additional_md_box.isVisible() + return self._hide_optional_metadata @hide_optional_metadata.setter def hide_optional_metadata(self, hide: bool): @@ -72,6 +73,7 @@ def hide_optional_metadata(self, hide: bool): Args: hide(bool): Hide or show the optional metadata table. """ + self._hide_optional_metadata = hide self._additional_md_box.setVisible(not hide) def get_form_data(self): @@ -96,7 +98,7 @@ def set_schema_from_scan(self, scan_name: str | None): from bec_lib.metadata_schema import BasicScanMetadata - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme class ExampleSchema1(BasicScanMetadata): abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C") @@ -140,7 +142,7 @@ class ExampleSchema3(BasicScanMetadata): layout.addWidget(selection) layout.addWidget(scan_metadata) - set_theme("dark") + apply_theme("dark") window = w window.show() app.exec() diff --git a/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject b/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject deleted file mode 100644 index 9c5276022..000000000 --- a/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['vscode.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/vscode/vscode.py b/bec_widgets/widgets/editors/vscode/vscode.py deleted file mode 100644 index 85584dd36..000000000 --- a/bec_widgets/widgets/editors/vscode/vscode.py +++ /dev/null @@ -1,203 +0,0 @@ -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() diff --git a/bec_widgets/widgets/editors/web_console/bec_shell.pyproject b/bec_widgets/widgets/editors/web_console/bec_shell.pyproject new file mode 100644 index 000000000..786a751fe --- /dev/null +++ b/bec_widgets/widgets/editors/web_console/bec_shell.pyproject @@ -0,0 +1 @@ +{'files': ['web_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py b/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py similarity index 71% rename from bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py rename to bec_widgets/widgets/editors/web_console/bec_shell_plugin.py index fb507cc67..92112c39f 100644 --- a/bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py +++ b/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py @@ -5,17 +5,17 @@ from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.editors.web_console.web_console import BECShell DOM_XML = """ - + """ -class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None @@ -23,20 +23,20 @@ def __init__(self): def createWidget(self, parent): if parent is None: return QWidget() - t = BECDockArea(parent) + t = BECShell(parent) return t def domXml(self): return DOM_XML def group(self): - return "BEC Containers" + return "" def icon(self): - return designer_material_icon(BECDockArea.ICON_NAME) + return designer_material_icon(BECShell.ICON_NAME) def includeFile(self): - return "bec_dock_area" + return "bec_shell" def initialize(self, form_editor): self._form_editor = form_editor @@ -48,7 +48,7 @@ def isInitialized(self): return self._form_editor is not None def name(self): - return "BECDockArea" + return "BECShell" def toolTip(self): return "" diff --git a/bec_widgets/widgets/containers/dock/register_bec_dock_area.py b/bec_widgets/widgets/editors/web_console/register_bec_shell.py similarity index 65% rename from bec_widgets/widgets/containers/dock/register_bec_dock_area.py rename to bec_widgets/widgets/editors/web_console/register_bec_shell.py index 2c33f79d9..3e5562989 100644 --- a/bec_widgets/widgets/containers/dock/register_bec_dock_area.py +++ b/bec_widgets/widgets/editors/web_console/register_bec_shell.py @@ -6,9 +6,9 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin + from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin - QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/editors/web_console/web_console.py b/bec_widgets/widgets/editors/web_console/web_console.py index e0ad7d4bd..c7ca75da8 100644 --- a/bec_widgets/widgets/editors/web_console/web_console.py +++ b/bec_widgets/widgets/editors/web_console/web_console.py @@ -1,21 +1,39 @@ from __future__ import annotations +import enum +import json import secrets import subprocess import time from bec_lib.logger import bec_logger from louie.saferef import safe_ref -from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler +from pydantic import BaseModel +from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler +from qtpy.QtGui import QMouseEvent, QResizeEvent from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeProperty logger = bec_logger.logger +class ConsoleMode(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + HIDDEN = "hidden" + + +class PageOwnerInfo(BaseModel): + owner_gui_id: str | None = None + widget_ids: list[str] = [] + page: QWebEnginePage | None = None + initialized: bool = False + + model_config = {"arbitrary_types_allowed": True} + + class WebConsoleRegistry: """ A registry for the WebConsole class to manage its instances. @@ -29,14 +47,21 @@ def __init__(self): self._server_process = None self._server_port = None self._token = secrets.token_hex(16) + self._page_registry: dict[str, PageOwnerInfo] = {} def register(self, instance: WebConsole): """ Register an instance of WebConsole. + + Args: + instance (WebConsole): The instance to register. """ self._instances[instance.gui_id] = safe_ref(instance) self.cleanup() + if instance._unique_id: + self._register_page(instance) + if self._server_process is None: # Start the ttyd server if not already running self.start_ttyd() @@ -141,8 +166,158 @@ def unregister(self, instance: WebConsole): if instance.gui_id in self._instances: del self._instances[instance.gui_id] + if instance._unique_id: + self._unregister_page(instance._unique_id, instance.gui_id) + self.cleanup() + def _register_page(self, instance: WebConsole): + """ + Register a page in the registry. Please note that this does not transfer ownership + for already existing pages; it simply records which widget currently owns the page. + Use transfer_page_ownership to change ownership. + + Args: + instance (WebConsole): The instance to register. + """ + + unique_id = instance._unique_id + gui_id = instance.gui_id + + if unique_id is None: + return + + if unique_id not in self._page_registry: + page = BECWebEnginePage() + page.authenticationRequired.connect(instance._authenticate) + self._page_registry[unique_id] = PageOwnerInfo( + owner_gui_id=gui_id, widget_ids=[gui_id], page=page + ) + logger.info(f"Registered new page {unique_id} for {gui_id}") + return + + if gui_id not in self._page_registry[unique_id].widget_ids: + self._page_registry[unique_id].widget_ids.append(gui_id) + + def _unregister_page(self, unique_id: str, gui_id: str): + """ + Unregister a page from the registry. + + Args: + unique_id (str): The unique identifier for the page. + gui_id (str): The GUI ID of the widget. + """ + if unique_id not in self._page_registry: + return + page_info = self._page_registry[unique_id] + if gui_id in page_info.widget_ids: + page_info.widget_ids.remove(gui_id) + if page_info.owner_gui_id == gui_id: + page_info.owner_gui_id = None + if not page_info.widget_ids: + if page_info.page: + page_info.page.deleteLater() + del self._page_registry[unique_id] + + logger.info(f"Unregistered page {unique_id} for {gui_id}") + + def get_page_info(self, unique_id: str) -> PageOwnerInfo | None: + """ + Get a page from the registry. + + Args: + unique_id (str): The unique identifier for the page. + + Returns: + PageOwnerInfo | None: The page info if found, None otherwise. + """ + if unique_id not in self._page_registry: + return None + return self._page_registry[unique_id] + + def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None: + """ + Transfer ownership of a page to a new owner. + + Args: + unique_id (str): The unique identifier for the page. + new_owner_gui_id (str): The GUI ID of the new owner. + + Returns: + QWebEnginePage | None: The page if ownership transfer was successful, None otherwise. + """ + if unique_id not in self._page_registry: + logger.warning(f"Page {unique_id} not found in registry") + return None + + page_info = self._page_registry[unique_id] + old_owner_gui_id = page_info.owner_gui_id + if old_owner_gui_id: + old_owner_ref = self._instances.get(old_owner_gui_id) + if old_owner_ref: + old_owner_instance = old_owner_ref() + if old_owner_instance: + old_owner_instance.yield_ownership() + page_info.owner_gui_id = new_owner_gui_id + + logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}") + return page_info.page + + def yield_ownership(self, gui_id: str) -> bool: + """ + Yield ownership of a page without destroying it. The page remains in the + registry with no owner, available for another widget to claim. + + Args: + gui_id (str): The GUI ID of the widget yielding ownership. + + Returns: + bool: True if ownership was yielded, False otherwise. + """ + if gui_id not in self._instances: + return False + + instance = self._instances[gui_id]() + if instance is None: + return False + + unique_id = instance._unique_id + if unique_id is None: + return False + + if unique_id not in self._page_registry: + return False + + page_owner_info = self._page_registry[unique_id] + if page_owner_info.owner_gui_id != gui_id: + return False + + page_owner_info.owner_gui_id = None + return True + + def owner_is_visible(self, unique_id: str) -> bool: + """ + Check if the owner of a page is currently visible. + + Args: + unique_id (str): The unique identifier for the page. + Returns: + bool: True if the owner is visible, False otherwise. + """ + page_info = self.get_page_info(unique_id) + if page_info is None or page_info.owner_gui_id is None: + return False + + owner_ref = self._instances.get(page_info.owner_gui_id) + if owner_ref is None: + return False + + owner_instance = owner_ref() + if owner_instance is None: + return False + + return owner_instance.isVisible() + _web_console_registry = WebConsoleRegistry() @@ -172,32 +347,109 @@ class WebConsole(BECWidget, QWidget): PLUGIN = True ICON_NAME = "terminal" - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, + parent=None, + config=None, + client=None, + gui_id=None, + startup_cmd: str | None = None, + is_bec_shell: bool = False, + unique_id: str | None = None, + **kwargs, + ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self._startup_cmd = "bec --nogui" + self._mode = ConsoleMode.INACTIVE + self._is_bec_shell = is_bec_shell + self._startup_cmd = startup_cmd self._is_initialized = False - _web_console_registry.register(self) - self._token = _web_console_registry._token - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.browser = QWebEngineView(self) - self.page = BECWebEnginePage(self) - self.page.authenticationRequired.connect(self._authenticate) - self.browser.setPage(self.page) - layout.addWidget(self.browser) - self.setLayout(layout) - self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + self._unique_id = unique_id + self.page = None # Will be set in _set_up_page + self._startup_timer = QTimer() self._startup_timer.setInterval(500) self._startup_timer.timeout.connect(self._check_page_ready) self._startup_timer.start() self._js_callback.connect(self._on_js_callback) + self._set_up_page() + + def _set_up_page(self): + """ + Set up the web page and UI elements. + """ + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.browser = QWebEngineView(self) + + layout.addWidget(self.browser) + self.setLayout(layout) + + # prepare overlay + self.overlay = QWidget(self) + layout = QVBoxLayout(self.overlay) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + label = QLabel("Click to activate terminal", self.overlay) + layout.addWidget(label) + self.overlay.hide() + + _web_console_registry.register(self) + self._token = _web_console_registry._token + + # If no unique_id is provided, create a new page + if not self._unique_id: + self.page = BECWebEnginePage(self) + self.page.authenticationRequired.connect(self._authenticate) + self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + self.browser.setPage(self.page) + self._set_mode(ConsoleMode.ACTIVE) + return + + # Try to get the page from the registry + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info and page_info.page: + self.page = page_info.page + if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id: + self.browser.setPage(self.page) + # Only set URL if this is a newly created page (no URL set yet) + if self.page.url().isEmpty(): + self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + else: + # We have an existing page, so we don't need the startup timer + self._startup_timer.stop() + if page_info.owner_gui_id != self.gui_id: + self._set_mode(ConsoleMode.INACTIVE) + else: + self._set_mode(ConsoleMode.ACTIVE) + + def _set_mode(self, mode: ConsoleMode): + """ + Set the mode of the web console. + + Args: + mode (ConsoleMode): The mode to set. + """ + if not self._unique_id: + # For non-unique_id consoles, always active + mode = ConsoleMode.ACTIVE + + self._mode = mode + match mode: + case ConsoleMode.ACTIVE: + self.browser.setVisible(True) + self.overlay.hide() + case ConsoleMode.INACTIVE: + self.browser.setVisible(False) + self.overlay.show() + case ConsoleMode.HIDDEN: + self.browser.setVisible(False) + self.overlay.hide() + def _check_page_ready(self): """ Check if the page is ready and stop the timer if it is. """ - if self.page.isLoading(): + if not self.page or self.page.isLoading(): return self.page.runJavaScript("window.term !== undefined", self._js_callback.emit) @@ -210,15 +462,27 @@ def _on_js_callback(self, ready: bool): return self._is_initialized = True self._startup_timer.stop() - if self._startup_cmd: - self.write(self._startup_cmd) + if self.startup_cmd: + if self._unique_id: + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + return + if not page_info.initialized: + page_info.initialized = True + self.write(self.startup_cmd) + else: + self.write(self.startup_cmd) self.initialized.emit() - @SafeProperty(str) + @property def startup_cmd(self): """ Get the startup command for the web console. """ + if self._is_bec_shell: + if self.bec_dispatcher.cli_server is None: + return "bec --nogui" + return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}" return self._startup_cmd @startup_cmd.setter @@ -233,11 +497,123 @@ def startup_cmd(self, cmd: str): def write(self, data: str, send_return: bool = True): """ Send data to the web page + + Args: + data (str): The data to send. + send_return (bool): Whether to send a return after the data. """ - self.page.runJavaScript(f"window.term.paste('{data}');") + cmd = f"window.term.paste({json.dumps(data)});" + if self.page is None: + logger.warning("Cannot write to web console: page is not initialized.") + return + self.page.runJavaScript(cmd) if send_return: self.send_return() + def take_page_ownership(self, unique_id: str | None = None): + """ + Take ownership of a web page from the registry. This will transfer the page + from its current owner (if any) to this widget. + + Args: + unique_id (str): The unique identifier of the page to take ownership of. + If None, uses this widget's unique_id. + """ + if unique_id is None: + unique_id = self._unique_id + + if not unique_id: + logger.warning("Cannot take page ownership without a unique_id") + return + + # Get the page from registry + page = _web_console_registry.take_page_ownership(unique_id, self.gui_id) + + if not page: + logger.warning(f"Page {unique_id} not found in registry") + return + + self.page = page + self.browser.setPage(page) + self._set_mode(ConsoleMode.ACTIVE) + logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}") + + def _on_ownership_lost(self): + """ + Called when this widget loses ownership of its page. + Displays the overlay and hides the browser. + """ + self._set_mode(ConsoleMode.INACTIVE) + logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}") + + def yield_ownership(self): + """ + Yield ownership of the page. The page remains in the registry with no owner, + available for another widget to claim. This is automatically called when the + widget becomes hidden. + """ + if not self._unique_id: + return + success = _web_console_registry.yield_ownership(self.gui_id) + if success: + self._on_ownership_lost() + logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}") + + def has_ownership(self) -> bool: + """ + Check if this widget currently has ownership of a page. + + Returns: + bool: True if this widget owns a page, False otherwise. + """ + if not self._unique_id: + return False + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + return False + return page_info.owner_gui_id == self.gui_id + + def hideEvent(self, event): + """ + Called when the widget is hidden. Automatically yields ownership. + """ + if self.has_ownership(): + self.yield_ownership() + self._set_mode(ConsoleMode.HIDDEN) + super().hideEvent(event) + + def showEvent(self, event): + """ + Called when the widget is shown. Updates UI state based on ownership. + """ + super().showEvent(event) + if self._unique_id and not self.has_ownership(): + # Take ownership if the page does not have an owner or + # the owner is not visible + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + self._set_mode(ConsoleMode.INACTIVE) + return + if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible( + self._unique_id + ): + self.take_page_ownership(self._unique_id) + return + if page_info.owner_gui_id != self.gui_id: + self._set_mode(ConsoleMode.INACTIVE) + return + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self.overlay.resize(event.size()) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership(): + self.take_page_ownership(self._unique_id) + event.accept() + return + return super().mousePressEvent(event) + def _authenticate(self, _, auth): """ Authenticate the request with the provided username and password. @@ -278,10 +654,52 @@ def cleanup(self): super().cleanup() +class BECShell(WebConsole): + """ + A WebConsole pre-configured to run the BEC shell. + We cannot simply expose the web console properties to Qt as we need to have a deterministic + startup behavior for sharing the same shell instance across multiple widgets. + """ + + ICON_NAME = "hub" + + def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + super().__init__( + parent=parent, + config=config, + client=client, + gui_id=gui_id, + is_bec_shell=True, + unique_id="bec_shell", + **kwargs, + ) + + if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - widget = WebConsole() + widget = QTabWidget() + + # Create two consoles with different unique_ids + web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") + web_console2 = WebConsole(startup_cmd="htop") + web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") + widget.addTab(web_console1, "Console 1") + widget.addTab(web_console2, "Console 2") + widget.addTab(web_console3, "Console 3 -- mirror of Console 1") widget.show() + + # Demonstrate page sharing: + # After initialization, web_console2 can take ownership of console1's page: + # web_console2.take_page_ownership("console1") + + widget.resize(800, 600) + + def _close_cons1(): + web_console2.close() + web_console2.deleteLater() + + # QTimer.singleShot(3000, _close_cons1) + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/website/website.py b/bec_widgets/widgets/editors/website/website.py index 7839b7891..fa9c8815d 100644 --- a/bec_widgets/widgets/editors/website/website.py +++ b/bec_widgets/widgets/editors/website/website.py @@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget): PLUGIN = True ICON_NAME = "travel_explore" - USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"] + USER_ACCESS = [ + "set_url", + "get_url", + "reload", + "back", + "forward", + "attach", + "detach", + "screenshot", + ] def __init__( self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs diff --git a/bec_widgets/widgets/games/minesweeper.py b/bec_widgets/widgets/games/minesweeper.py index 607cde57c..ad9e496f6 100644 --- a/bec_widgets/widgets/games/minesweeper.py +++ b/bec_widgets/widgets/games/minesweeper.py @@ -407,10 +407,10 @@ def cleanup(self): if __name__ == "__main__": - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("light") + apply_theme("light") widget = Minesweeper() widget.show() diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 05501b9f0..3266d9b74 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from dataclasses import dataclass from typing import Literal import numpy as np @@ -8,7 +9,7 @@ from bec_lib import bec_logger, messages from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import QTimer, Signal +from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal from qtpy.QtGui import QTransform from scipy.interpolate import ( CloughTocher2DInterpolator, @@ -78,6 +79,90 @@ class HeatmapConfig(ConnectionConfig): _validate_color_palette = field_validator("color_map")(Colors.validate_color_map) +@dataclass +class _InterpolationRequest: + """Immutable payload describing an interpolation request for the worker thread. + + Args: + x_data: X coordinates collected so far. + y_data: Y coordinates collected so far. + z_data: Z values associated with x/y. + data_version: Number of points at request time (len(z_data)); used to reject stale results. + scan_id: Identifier for the scan that produced the data. + interpolation: Interpolation method to apply. + oversampling_factor: Oversampling factor for the interpolation grid. + """ + + x_data: list[float] + y_data: list[float] + z_data: list[float] + data_version: int + scan_id: str + interpolation: str + oversampling_factor: float + + +class _StepInterpolationWorker(QObject): + """Worker for performing step-scan interpolation in a background thread. + + This worker computes the interpolated heatmap image using the provided data + and settings, then emits the result or a failure signal. + + Signals: + finished(image, transform, data_version, scan_id): + Emitted when interpolation is successful. + - image: The resulting image (numpy array or similar). + - transform: The QTransform for the image. + - data_version: The data version for the request. + - scan_id: The scan identifier. + failed(error_message, data_version, scan_id): + Emitted when interpolation fails. + - error_message: The error message string. + - data_version: The data version for the request. + - scan_id: The scan identifier. + """ + + finished = Signal(object, object, int, str) + failed = Signal(str, int, str) + + def __init__(self, parent: QObject | None = None): + super().__init__(parent=parent) + self._active_request: _InterpolationRequest | None = None + self._processing = False + + @property + def is_processing(self) -> bool: + """Return whether the worker is currently processing a request.""" + return self._processing + + @SafeSlot(object, int) + def process(self, request: _InterpolationRequest, data_version: int): + """ + Process an interpolation request in the worker thread. + + Args: + request(_InterpolationRequest): The interpolation request payload. + data_version(int): The data version for the request. + """ + self._active_request = request + self._processing = True + try: + image, transform = Heatmap.compute_step_scan_image( + x_data=np.asarray(request.x_data, dtype=float), + y_data=np.asarray(request.y_data, dtype=float), + z_data=np.asarray(request.z_data, dtype=float), + oversampling_factor=request.oversampling_factor, + interpolation_method=request.interpolation, + ) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Step-scan interpolation failed with: {exc}") + self.failed.emit(str(exc), data_version, request.scan_id) + self._processing = False + return + self._processing = False + self.finished.emit(image, transform, data_version, request.scan_id) + + class Heatmap(ImageBase): """ Heatmap widget for visualizing 2d grid data with color mapping for the z-axis. @@ -118,6 +203,19 @@ class Heatmap(ImageBase): "remove_roi", "rois", "plot", + # Device properties + "x_device_name", + "x_device_name.setter", + "x_device_entry", + "x_device_entry.setter", + "y_device_name", + "y_device_name.setter", + "y_device_entry", + "y_device_entry.setter", + "z_device_name", + "z_device_name.setter", + "z_device_entry", + "z_device_entry.setter", ] PLUGIN = True @@ -128,6 +226,7 @@ class Heatmap(ImageBase): new_scan_id = Signal(str) sync_signal_update = Signal() heatmap_property_changed = Signal() + interpolation_requested = Signal(object, int) def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs): if config is None: @@ -150,6 +249,12 @@ def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs): self.scan_item = None self.status_message = None self._grid_index = None + # Highest data_version we have dispatched for the current scan; used to drop stale results. + # Initialized to -1 so the first real request (len(z_data) >= 0) always supersedes it. + self._latest_interpolation_version = -1 + self._interpolation_thread: QThread | None = None + self._interpolation_worker: _StepInterpolationWorker | None = None + self._pending_interpolation_request: _InterpolationRequest | None = None self.heatmap_dialog = None bg_color = pg.mkColor((240, 240, 240, 150)) self.config_label = pg.LegendItem( @@ -321,9 +426,15 @@ def update_labels(self): """ if self._image_config is None: return - x_name = self._image_config.x_device.name - y_name = self._image_config.y_device.name - z_name = self._image_config.z_device.name + + # Safely get device names (might be None if not yet configured) + x_device = self._image_config.x_device + y_device = self._image_config.y_device + z_device = self._image_config.z_device + + x_name = x_device.name if x_device else None + y_name = y_device.name if y_device else None + z_name = z_device.name if z_device else None if x_name is not None: self.x_label = x_name # type: ignore @@ -426,6 +537,7 @@ def on_scan_status(self, msg: dict, meta: dict): if current_scan_id is None: return if current_scan_id != self.scan_id: + self._invalidate_interpolation_generation() # Invalidate any pending interpolation work when a new scan starts self.reset() self.new_scan.emit() self.new_scan_id.emit(current_scan_id) @@ -531,13 +643,38 @@ def update_plot(self, _=None) -> None: if self._image_config.show_config_label: self.redraw_config_label() - img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data) - if img is None: + if self._is_grid_scan_supported(scan_msg): + img, transform = self.get_grid_scan_image(z_data, scan_msg) + self._apply_image_update(img, transform) + return + + if len(z_data) < 4: + # LinearNDInterpolator requires at least 4 points to interpolate + logger.warning("Not enough data points to interpolate; skipping update.") + return + + self._request_step_scan_interpolation(x_data, y_data, z_data, scan_msg) + + def _apply_image_update(self, img: np.ndarray | None, transform: QTransform | None): + """Apply interpolated image and transform to the heatmap display. + + This method updates the main image with the computed data and emits + the image_updated signal. Color bar signals are temporarily blocked + during the update to prevent cascading updates. + + Args: + img(np.ndarray): The interpolated image data, or None if unavailable + transform(QTransform): QTransform mapping pixel to world coordinates, or None if unavailable + """ + if img is None or transform is None: logger.warning("Image data is None; skipping update.") return if self._color_bar is not None: self._color_bar.blockSignals(True) + if self.main_image is None: + logger.warning("Main image item is None; cannot update image.") + return self.main_image.set_data(img, transform=transform) if self._color_bar is not None: self._color_bar.blockSignals(False) @@ -545,6 +682,128 @@ def update_plot(self, _=None) -> None: if self.crosshair is not None: self.crosshair.update_markers_on_image_change() + def _request_step_scan_interpolation( + self, + x_data: list[float], + y_data: list[float], + z_data: list[float], + msg: messages.ScanStatusMessage, + ): + """Request step-scan interpolation in a background thread. + + If a thread is already running, the request is queued as a pending request + and will be processed when the current interpolation completes. + + Args: + x_data(list[float]): X coordinates of data points + y_data(list[float]): Y coordinates of data points + z_data(list[float]): Z values at each point + msg(messages.ScanStatusMessage): Scan status message containing scan metadata + """ + request = _InterpolationRequest( + x_data=list(x_data), + y_data=list(y_data), + z_data=list(z_data), + data_version=len(z_data), + scan_id=msg.scan_id, + interpolation=self._image_config.interpolation, + oversampling_factor=self._image_config.oversampling_factor, + ) + + if self._interpolation_worker is not None and self._interpolation_worker.is_processing: + self._pending_interpolation_request = request + return + + self._start_step_scan_interpolation(request) + + def _ensure_interpolation_thread(self): + if self._interpolation_thread is None: + self._interpolation_thread = QThread() + self._interpolation_worker = _StepInterpolationWorker() + self._interpolation_worker.moveToThread(self._interpolation_thread) + self.interpolation_requested.connect( + self._interpolation_worker.process, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.finished.connect( + self._on_interpolation_finished, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.failed.connect( + self._on_interpolation_failed, Qt.ConnectionType.QueuedConnection + ) + if self._interpolation_thread is not None and not self._interpolation_thread.isRunning(): + self._interpolation_thread.start() + + def _start_step_scan_interpolation(self, request: _InterpolationRequest): + # data_version = len(z_data) at the time of the request; keep the latest to gate results. + self._ensure_interpolation_thread() + if self._interpolation_thread is not None and not self._interpolation_thread.isRunning(): + self._interpolation_thread.start() + self._latest_interpolation_version = request.data_version + self.interpolation_requested.emit(request, request.data_version) + + def _on_interpolation_finished( + self, img: np.ndarray, transform: QTransform, data_version: int, scan_id: str + ): + # Only accept results that match the latest dispatched version for the active scan. + if data_version == self._latest_interpolation_version and scan_id == self.scan_id: + self._apply_image_update(img, transform) + else: + logger.info("Discarding outdated interpolation result.") + self._maybe_start_pending_interpolation() + + def _on_interpolation_failed(self, error: str, data_version: int, scan_id: str): + logger.warning(f"Interpolation failed for scan {scan_id} (version {data_version}): {error}") + self._maybe_start_pending_interpolation() + + def _finish_interpolation_thread(self): + self._pending_interpolation_request = None + if self._interpolation_worker is not None: + try: + self.interpolation_requested.disconnect(self._interpolation_worker.process) + except (TypeError, RuntimeError) as ext: + logger.warning(f"Processing thread already disconnected: {ext}") + pass + self._interpolation_worker.deleteLater() + self._interpolation_worker = None + if self._interpolation_thread is not None: + if self._interpolation_thread.isRunning(): + self._interpolation_thread.quit() + if not self._interpolation_thread.wait(3000): # 3s timeout + logger.error( + f"Interpolation thread of widget {self.gui_id} did not stop within timeout 3s; leaving it dangling." + ) + self._interpolation_thread.deleteLater() + self._interpolation_thread = None + logger.info(f"Interpolation thread finished of widget {self.gui_id}") + + def _maybe_start_pending_interpolation(self): + if self._pending_interpolation_request is None: + return + if self._pending_interpolation_request.scan_id != self.scan_id: + self._pending_interpolation_request = None + return + if self._interpolation_worker is not None and self._interpolation_worker.is_processing: + return + + pending = self._pending_interpolation_request + self._pending_interpolation_request = None + self._start_step_scan_interpolation(pending) + + def _cancel_interpolation(self): + """Cancel any pending interpolation request without invalidating in-flight work. + + This clears the pending request queue but does not invalidate in-flight work, + allowing any currently running interpolation to complete and update the display + if it matches the current scan. + """ + self._pending_interpolation_request = None + # Do not change the active data version so an in-flight worker can still deliver. + + def _invalidate_interpolation_generation(self): + """Invalidate all pending interpolation results and ignore in-flight updates.""" + self._pending_interpolation_request = None + self._latest_interpolation_version = -1 + def redraw_config_label(self): scan_msg = self.status_message if scan_msg is None: @@ -590,21 +849,35 @@ def get_image_data( logger.warning("x, y, or z data is None; skipping update.") return None, None - if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation: - # We only support the grid scan mode if both scanning motors - # are configured in the heatmap config. - device_x = self._image_config.x_device.entry - device_y = self._image_config.y_device.entry - if ( - device_x in msg.request_inputs["arg_bundle"] - and device_y in msg.request_inputs["arg_bundle"] - ): - return self.get_grid_scan_image(z_data, msg) + if self._is_grid_scan_supported(msg): + return self.get_grid_scan_image(z_data, msg) if len(z_data) < 4: # LinearNDInterpolator requires at least 4 points to interpolate return None, None return self.get_step_scan_image(x_data, y_data, z_data, msg) + def _is_grid_scan_supported(self, msg: messages.ScanStatusMessage) -> bool: + """Check if the scan can use optimized grid_scan rendering. + + Grid scans can avoid interpolation if both X and Y devices match + the configured devices and interpolation is not enforced. + + Args: + msg(messages.ScanStatusMessage): Scan status message containing scan metadata + + Returns: + True if grid_scan optimization is applicable, False otherwise + """ + if msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation: + return False + + device_x = self._image_config.x_device.entry + device_y = self._image_config.y_device.entry + return ( + device_x in msg.request_inputs["arg_bundle"] + and device_y in msg.request_inputs["arg_bundle"] + ) + def get_grid_scan_image( self, z_data: list[float], msg: messages.ScanStatusMessage ) -> tuple[np.ndarray, QTransform]: @@ -704,17 +977,49 @@ def get_step_scan_image( Returns: tuple[np.ndarray, QTransform]: The image data and the QTransform. """ + return self.compute_step_scan_image( + x_data=x_data, + y_data=y_data, + z_data=z_data, + oversampling_factor=self._image_config.oversampling_factor, + interpolation_method=self._image_config.interpolation, + ) + + @staticmethod + def compute_step_scan_image( + x_data: list[float] | np.ndarray, + y_data: list[float] | np.ndarray, + z_data: list[float] | np.ndarray, + oversampling_factor: float, + interpolation_method: str, + ) -> tuple[np.ndarray, QTransform]: + """Compute interpolated heatmap image from step-scan data. + + This static method is suitable for execution in a background thread + as it doesn't access any instance state. + + Args: + x_data(list[float]): X coordinates of data points + y_data(list[float]): Y coordinates of data points + z_data(list[float]): Z values at each point + oversampling_factor(float): Grid resolution multiplier (>1.0 for higher resolution) + interpolation_method(str): One of 'linear', 'nearest', or 'clough' + + Returns: + (tuple[np.ndarray, QTransform]):Tuple of (interpolated_grid, transform) where transform maps pixel to world coordinates + """ xy_data = np.column_stack((x_data, y_data)) - grid_x, grid_y, transform = self.get_image_grid(xy_data) + grid_x, grid_y, transform = Heatmap.build_image_grid( + positions=xy_data, oversampling_factor=oversampling_factor + ) - # Interpolate the z data onto the grid - if self._image_config.interpolation == "linear": + if interpolation_method == "linear": interp = LinearNDInterpolator(xy_data, z_data) - elif self._image_config.interpolation == "nearest": + elif interpolation_method == "nearest": interp = NearestNDInterpolator(xy_data, z_data) - elif self._image_config.interpolation == "clough": + elif interpolation_method == "clough": interp = CloughTocher2DInterpolator(xy_data, z_data) - else: + else: # pragma: no cover - guarded by validation raise ValueError( "Interpolation method must be either 'linear', 'nearest', or 'clough'." ) @@ -733,22 +1038,33 @@ def get_image_grid(self, positions) -> tuple[np.ndarray, np.ndarray, QTransform] Returns: tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform. """ - base_width, base_height = self.estimate_image_resolution(positions) + return self.build_image_grid( + positions=positions, oversampling_factor=self._image_config.oversampling_factor + ) - # Apply oversampling factor - factor = self._image_config.oversampling_factor + @staticmethod + def build_image_grid( + positions: np.ndarray, oversampling_factor: float + ) -> tuple[np.ndarray, np.ndarray, QTransform]: + """Build an interpolation grid covering the data positions. - # Apply oversampling - width = int(base_width * factor) - height = int(base_height * factor) + Args: + positions: (N, 2) array of (x, y) coordinates + oversampling_factor: Grid resolution multiplier (>1.0 for higher resolution) + + Returns: + Tuple of (grid_x, grid_y, transform) where grid_x/grid_y are meshgrids + for interpolation and transform maps pixel to world coordinates + """ + base_width, base_height = Heatmap.estimate_image_resolution(positions) + width = max(1, int(base_width * oversampling_factor)) + height = max(1, int(base_height * oversampling_factor)) - # Create grid grid_x, grid_y = np.mgrid[ min(positions[:, 0]) : max(positions[:, 0]) : width * 1j, min(positions[:, 1]) : max(positions[:, 1]) : height * 1j, ] - # Calculate transform x_min, x_max = min(positions[:, 0]), max(positions[:, 0]) y_min, y_max = min(positions[:, 1]), max(positions[:, 1]) x_range = x_max - x_min @@ -832,12 +1148,251 @@ def _fetch_scan_data_and_access(self): return scan_devices, "value" def reset(self): + self._cancel_interpolation() self._grid_index = None self.main_image.clear() if self.crosshair is not None: self.crosshair.reset() super().reset() + ################################################################################ + # Widget Specific Properties + ################################################################################ + + @SafeProperty(str) + def x_device_name(self) -> str: + """Device name for the X axis.""" + if self._image_config.x_device is None: + return "" + return self._image_config.x_device.name or "" + + @x_device_name.setter + def x_device_name(self, device_name: str) -> None: + """ + Set the X device name. + + Args: + device_name(str): Device name for the X axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.x_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("x_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.x_device = None + self.property_changed.emit("x_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def x_device_entry(self) -> str: + """Signal entry for the X axis device.""" + if self._image_config.x_device is None: + return "" + return self._image_config.x_device.entry or "" + + @x_device_entry.setter + def x_device_entry(self, entry: str) -> None: + """ + Set the X device entry. + + Args: + entry(str): Signal entry for the X axis device + """ + if not entry: + return + + if self._image_config.x_device is None: + logger.warning("Cannot set x_device_entry without x_device_name set first.") + return + + device_name = self._image_config.x_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.x_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("x_device_entry", validated_entry) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def y_device_name(self) -> str: + """Device name for the Y axis.""" + if self._image_config.y_device is None: + return "" + return self._image_config.y_device.name or "" + + @y_device_name.setter + def y_device_name(self, device_name: str) -> None: + """ + Set the Y device name. + + Args: + device_name(str): Device name for the Y axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.y_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("y_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.y_device = None + self.property_changed.emit("y_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def y_device_entry(self) -> str: + """Signal entry for the Y axis device.""" + if self._image_config.y_device is None: + return "" + return self._image_config.y_device.entry or "" + + @y_device_entry.setter + def y_device_entry(self, entry: str) -> None: + """ + Set the Y device entry. + + Args: + entry(str): Signal entry for the Y axis device + """ + if not entry: + return + + if self._image_config.y_device is None: + logger.warning("Cannot set y_device_entry without y_device_name set first.") + return + + device_name = self._image_config.y_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.y_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("y_device_entry", validated_entry) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception as e: + logger.debug(f"Y device entry validation failed: {e}") + pass # Silently fail if validation fails + + @SafeProperty(str) + def z_device_name(self) -> str: + """Device name for the Z (color) axis.""" + if self._image_config.z_device is None: + return "" + return self._image_config.z_device.name or "" + + @z_device_name.setter + def z_device_name(self, device_name: str) -> None: + """ + Set the Z device name. + + Args: + device_name(str): Device name for the Z axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.z_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("z_device_name", device_name) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device name validation failed: {e}") + pass # Silently fail if device is not available yet + else: + self._image_config.z_device = None + self.property_changed.emit("z_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def z_device_entry(self) -> str: + """Signal entry for the Z (color) axis device.""" + if self._image_config.z_device is None: + return "" + return self._image_config.z_device.entry or "" + + @z_device_entry.setter + def z_device_entry(self, entry: str) -> None: + """ + Set the Z device entry. + + Args: + entry(str): Signal entry for the Z axis device + """ + if not entry: + return + + if self._image_config.z_device is None: + logger.warning("Cannot set z_device_entry without z_device_name set first.") + return + + device_name = self._image_config.z_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.z_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("z_device_entry", validated_entry) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device entry validation failed: {e}") + pass # Silently fail if validation fails + + def _try_auto_plot(self) -> None: + """ + Attempt to automatically call plot() if all three devices are set. + Similar to waveform's approach but requires all three devices. + """ + has_x = self._image_config.x_device is not None + has_y = self._image_config.y_device is not None + has_z = self._image_config.z_device is not None + + if has_x and has_y and has_z: + x_name = self._image_config.x_device.name + x_entry = self._image_config.x_device.entry + y_name = self._image_config.y_device.name + y_entry = self._image_config.y_device.entry + z_name = self._image_config.z_device.name + z_entry = self._image_config.z_device.entry + try: + self.plot( + x_name=x_name, + y_name=y_name, + z_name=z_name, + x_entry=x_entry, + y_entry=y_entry, + z_entry=z_entry, + validate_bec=False, # Don't validate - entries already validated + ) + except Exception as e: + logger.debug(f"Auto-plot failed: {e}") + pass # Silently fail if plot cannot be called yet + @SafeProperty(str) def interpolation_method(self) -> str: """ @@ -898,7 +1453,7 @@ def enforce_interpolation(self, value: bool): # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -915,7 +1470,7 @@ def fft(self, enable: bool): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -949,7 +1504,7 @@ def num_rotation_90(self, value: int): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. @@ -966,6 +1521,10 @@ def transpose(self, enable: bool): """ self.main_image.transpose = enable + def cleanup(self): + self._finish_interpolation_thread() + super().cleanup() + if __name__ == "__main__": # pragma: no cover import sys diff --git a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py index 84dfa6106..c55d34064 100644 --- a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py +++ b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout @@ -9,11 +8,6 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.settings_dialog import SettingWidget -if TYPE_CHECKING: - from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( - SignalComboBox, - ) - class HeatmapSettings(SettingWidget): def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs): @@ -120,36 +114,17 @@ def fetch_all_properties(self): getattr(self.target_widget._image_config, "enforce_interpolation", False) ) - def _get_signal_name(self, signal: SignalComboBox) -> str: - """ - Get the signal name from the signal combobox. - Args: - signal (SignalComboBox): The signal combobox to get the name from. - Returns: - str: The signal name. - """ - device_entry = signal.currentText() - index = signal.findText(device_entry) - if index == -1: - return device_entry - - device_entry_info = signal.itemData(index) - if device_entry_info: - device_entry = device_entry_info.get("obj_name", device_entry) - - return device_entry if device_entry else "" - @SafeSlot() def accept_changes(self): """ Apply all properties from the settings widget to the target widget. """ x_name = self.ui.x_name.currentText() - x_entry = self._get_signal_name(self.ui.x_entry) + x_entry = self.ui.x_entry.get_signal_name() y_name = self.ui.y_name.currentText() - y_entry = self._get_signal_name(self.ui.y_entry) + y_entry = self.ui.y_entry.get_signal_name() z_name = self.ui.z_name.currentText() - z_entry = self._get_signal_name(self.ui.z_entry) + z_entry = self.ui.z_entry.get_signal_name() validate_bec = self.ui.validate_bec.checked color_map = self.ui.color_map.colormap interpolation = self.ui.interpolation.currentText() diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index a3c01be39..a8e9b3b16 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -1,27 +1,25 @@ from __future__ import annotations from collections import defaultdict -from typing import Literal, Sequence +from typing import Literal import numpy as np from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QWidget from bec_widgets.utils import ConnectionConfig -from bec_widgets.utils.colors import Colors +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( - BECDeviceFilter, - ReadoutPriority, -) -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_item import ImageItem +from bec_widgets.widgets.plots.image.toolbar_components.device_selection import ( + DeviceSelection, + DeviceSelectionConnection, + device_selection_bundle, +) from bec_widgets.widgets.plots.plot_base import PlotBase logger = bec_logger.logger @@ -44,11 +42,19 @@ class ImageConfig(ConnectionConfig): class ImageLayerConfig(BaseModel): - monitor: str | tuple | None = Field(None, description="The name of the monitor.") - monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.") - source: Literal["device_monitor_1d", "device_monitor_2d", "auto"] = Field( - "auto", description="The source of the image data." + device_name: str = Field("", description="The device name to monitor.") + device_entry: str = Field("", description="The signal/entry name to monitor on the device.") + monitor_type: Literal["1d", "2d"] | None = Field(None, description="The type of monitor.") + source: Literal["device_monitor_1d", "device_monitor_2d"] | None = Field( + None, description="The source of the image data." + ) + async_signal_name: str | None = Field( + None, description="Async signal name (obj_name) used for async endpoints." + ) + connection_status: Literal["connected", "disconnected", "error"] = Field( + "disconnected", description="Current connection status." ) + connection_error: str | None = Field(None, description="Last connection error, if any.") class Image(ImageBase): @@ -74,8 +80,10 @@ class Image(ImageBase): "autorange.setter", "autorange_mode", "autorange_mode.setter", - "monitor", - "monitor.setter", + "device_name", + "device_name.setter", + "device_entry", + "device_entry.setter", "enable_colorbar", "enable_simple_colorbar", "enable_simple_colorbar.setter", @@ -96,6 +104,8 @@ class Image(ImageBase): "rois", ] + SUPPORTED_SIGNALS = ["AsyncSignal", "AsyncMultiSignal", "DynamicSignal"] + def __init__( self, parent: QWidget | None = None, @@ -108,15 +118,27 @@ def __init__( if config is None: config = ImageConfig(widget_class=self.__class__.__name__) self.gui_id = config.gui_id - self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict( - lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto") - ) + self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(ImageLayerConfig) + # Store signal configs separately (not serialized to QSettings) + self._signal_configs: dict[str, dict] = {} + super().__init__( parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs ) + self._device_selection_updating = False + self._autorange_on_next_update = False self._init_toolbar_image() self.layer_removed.connect(self._on_layer_removed) + self.old_scan_id = None self.scan_id = None + self.async_update = False + self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) + + @property + def _config(self) -> ImageLayerConfig: + """Helper property to access the main layer config.""" + return self.subscriptions["main"] ################################## ### Toolbar Initialization @@ -126,46 +148,21 @@ def _init_toolbar_image(self): """ Initializes the toolbar for the image widget. """ - self.device_combo_box = DeviceComboBox( - parent=self, - device_filter=BECDeviceFilter.DEVICE, - readout_priority_filter=[ReadoutPriority.ASYNC], - ) - self.device_combo_box.addItem("", None) - self.device_combo_box.setCurrentText("") - self.device_combo_box.setToolTip("Select Device") - self.device_combo_box.setFixedWidth(150) - self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) - - self.dim_combo_box = QComboBox(parent=self) - self.dim_combo_box.addItems(["auto", "1d", "2d"]) - self.dim_combo_box.setCurrentText("auto") - self.dim_combo_box.setToolTip("Monitor Dimension") - self.dim_combo_box.setFixedWidth(100) - self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) - - self.toolbar.components.add_safe( - "image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False) + self.toolbar.add_bundle( + device_selection_bundle(self.toolbar.components, client=self.client) ) - self.toolbar.components.add_safe( - "image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False) + self.toolbar.connect_bundle( + "device_selection", + DeviceSelectionConnection(self.toolbar.components, target_widget=self), ) - bundle = ToolbarBundle("monitor_selection", self.toolbar.components) - bundle.add_action("image_device_combo") - bundle.add_action("image_dim_combo") - - self.toolbar.add_bundle(bundle) - self.device_combo_box.currentTextChanged.connect(self.connect_monitor) - self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) - crosshair_bundle = self.toolbar.get_bundle("image_crosshair") crosshair_bundle.add_action("image_autorange") crosshair_bundle.add_action("image_colorbar_switch") self.toolbar.show_bundles( [ - "monitor_selection", + "device_selection", "plot_export", "mouse_interaction", "image_crosshair", @@ -178,94 +175,359 @@ def _init_toolbar_image(self): def _adjust_and_connect(self): """ - Adjust the size of the device combo box and populate it with preview signals. + Sync the device selection toolbar with current properties. Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing. - """ - self._populate_preview_signals() - self._reverse_device_items() - self.device_combo_box.setCurrentText("") # set again default to empty string - def _populate_preview_signals(self) -> None: - """ - Populate the device combo box with preview-signal devices in the - format '_' and store the tuple(device, signal) in - the item's userData for later use. + Note: DeviceComboBox and SignalComboBox auto-populate themselves, no manual population needed. """ - preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal") - for device, signal, signal_config in preview_signals: - label = signal_config.get("obj_name", f"{device}_{signal}") - self.device_combo_box.addItem(label, (device, signal, signal_config)) + self._sync_device_selection() - def _reverse_device_items(self) -> None: + @SafeSlot() + def on_device_selection_changed(self, _): """ - Reverse the current order of items in the device combo box while - keeping their userData and restoring the previous selection. + Called when device or signal selection changes in the toolbar. + This reads from the toolbar and updates the widget properties. """ - current_text = self.device_combo_box.currentText() - items = [ - (self.device_combo_box.itemText(i), self.device_combo_box.itemData(i)) - for i in range(self.device_combo_box.count()) - ] - self.device_combo_box.clear() - for text, data in reversed(items): - self.device_combo_box.addItem(text, data) - if current_text: - self.device_combo_box.setCurrentText(current_text) + if self._device_selection_updating: + return - @SafeSlot() - def connect_monitor(self, *args, **kwargs): - """ - Connect the target widget to the selected monitor based on the current device and dimension. + self._device_selection_updating = True + try: + try: + action = self.toolbar.components.get_action("device_selection") + except Exception: + return - If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor. - """ - dim = self.dim_combo_box.currentText() - data = self.device_combo_box.currentData() + if action is None: + return - if isinstance(data, tuple): - self.image(monitor=data, monitor_type="auto") - else: - self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) + device_selection: DeviceSelection = action.widget + device = device_selection.device_combo_box.currentText() + signal_text = device_selection.signal_combo_box.currentText() + + if not device: + self.device_name = "" + return + if not device_selection.device_combo_box.is_valid_input: + return + + if not device_selection.signal_combo_box.is_valid_input: + if self._config.device_entry: + self.device_entry = "" + if device != self._config.device_name: + self.device_name = device + return + + if device == self._config.device_name and signal_text == self._config.device_entry: + return + + # Get the signal config stored in the combobox + signal_config = device_selection.signal_combo_box.get_signal_config() + + if not signal_config: + # Fallback: try to get config from device + try: + device_obj = self.dev[device] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + logger.warning(f"Could not get signal config for {device}.{signal_text}") + signal_config = None + + # Store signal config and set properties which will trigger the connection + self._signal_configs["main"] = signal_config + self.device_name = device + self.device_entry = signal_text + finally: + self._device_selection_updating = False ################################################################################ # Data Acquisition - @SafeProperty(str) - def monitor(self) -> str: + @SafeProperty(str, auto_emit=True) + def device_name(self) -> str: """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - return self.subscriptions["main"].monitor or "" + return self._config.device_name - @monitor.setter - def monitor(self, value: str): + @device_name.setter + def device_name(self, value: str): """ - Set the monitor for the image. + Set the device name for the image. This should be used together with device_entry. + When both device_name and device_entry are set, the widget connects to that device signal. Args: - value(str): The name of the monitor to set. + value(str): The name of the device to monitor. """ - if self.subscriptions["main"].monitor == value: + if not value: + # Clear the monitor if empty device name + if self._config.device_name: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - try: - self.entry_validator.validate_monitor(value) - except ValueError: + + old_device = self._config.device_name + self._config.device_name = value + + # If we have a device_entry, reconnect with the new device + if self._config.device_entry: + # Try to get fresh signal config for the new device + try: + device_obj = self.dev[value] + # Try to get signal config for the current entry + if self._config.device_entry in device_obj._info.get("signals", {}): + self._signal_configs["main"] = device_obj._info["signals"][ + self._config.device_entry + ] + self._setup_connection() + else: + # Signal doesn't exist on new device + logger.warning( + f"Signal '{self._config.device_entry}' doesn't exist on device '{value}'" + ) + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status( + "error", f"Signal '{self._config.device_entry}' doesn't exist" + ) + except (KeyError, AttributeError): + # Device doesn't exist + logger.warning(f"Device '{value}' not found") + if old_device: + self._disconnect_current_monitor() + self._set_connection_status("error", f"Device '{value}' not found") + + # Toolbar sync happens via SafeProperty auto_emit property_changed handling. + + @SafeProperty(str, auto_emit=True) + def device_entry(self) -> str: + """ + The signal/entry name to monitor on the device. + """ + return self._config.device_entry + + @device_entry.setter + def device_entry(self, value: str): + """ + Set the device entry (signal) for the image. This should be used together with device_name. + When set, it will connect to updates from that device signal. + + Args: + value(str): The signal name to monitor. + """ + if not value: + if self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - self.image(monitor=value) + + self._config.device_entry = value + + # If we have a device_name, try to connect + if self._config.device_name: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(value) + if not isinstance(signal_config, dict) or not signal_config.get("signal_class"): + logger.warning( + f"Could not find valid configuration for signal '{value}' " + f"on device '{self._config.device_name}'." + ) + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + return + + self._signal_configs["main"] = signal_config + self._setup_connection() + except (KeyError, AttributeError): + logger.warning( + f"Could not find signal '{value}' on device '{self._config.device_name}'." + ) + # Remove signal config if it can't be fetched + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + + else: + logger.debug(f"device_entry setter: No device set yet for signal '{value}'") @property def main_image(self) -> ImageItem: """Access the main image item.""" return self.layer_manager["main"].image + def _setup_connection(self): + """ + Internal method to setup connection based on current device_name, device_entry, and signal_config. + """ + if not self._config.device_name or not self._config.device_entry: + logger.warning("Cannot setup connection without both device_name and device_entry") + self._set_connection_status("disconnected") + return + + signal_config = self._signal_configs.get("main") + if not signal_config: + logger.warning( + f"Cannot setup connection for {self._config.device_name}.{self._config.device_entry} without signal_config" + ) + self._set_connection_status("error", "Missing signal config") + return + + # Disconnect any existing monitor first + self._disconnect_current_monitor() + + # Determine monitor type and source from signal_config + signal_class = signal_config.get("signal_class", None) + supported_classes = ["PreviewSignal"] + self.SUPPORTED_SIGNALS + + if signal_class not in supported_classes: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' has unsupported signal class '{signal_class}'. " + f"Supported classes: {supported_classes}" + ) + self._set_connection_status("error", f"Unsupported signal class '{signal_class}'") + return + + describe = signal_config.get("describe") or {} + signal_info = describe.get("signal_info") or {} + ndim = signal_info.get("ndim", None) + + if ndim is None: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' does not have a valid 'ndim' in its signal_info." + ) + self._set_connection_status("error", "Missing ndim in signal_info") + return + + config = self.subscriptions["main"] + self.async_update = False + config.async_signal_name = None + + if ndim == 1: + config.source = "device_monitor_1d" + config.monitor_type = "1d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_1d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + elif ndim == 2: + config.source = "device_monitor_2d" + config.monitor_type = "2d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + else: + logger.warning( + f"Unsupported ndim '{ndim}' for monitor '{self._config.device_name}.{self._config.device_entry}'." + ) + self._set_connection_status("error", f"Unsupported ndim '{ndim}'") + return + + self._set_connection_status("connected") + logger.info( + f"Connected to {self._config.device_name}.{self._config.device_entry} with type {config.monitor_type}" + ) + self._autorange_on_next_update = True + + def _disconnect_current_monitor(self): + """ + Internal method to disconnect the current monitor subscriptions. + """ + if not self._config.device_name or not self._config.device_entry: + return + + config = self.subscriptions["main"] + + if self.async_update: + async_signal_name = config.async_signal_name or self._config.device_entry + ids_to_check = [self.scan_id, self.old_scan_id] + + if config.source == "device_monitor_1d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_async_signal( + scan_id, self._config.device_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}" + ) + elif config.source == "device_monitor_2d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_async_signal( + scan_id, self._config.device_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}" + ) + + else: + if config.source == "device_monitor_1d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 1d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + elif config.source == "device_monitor_2d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 2d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + + # Reset async state + self.async_update = False + config.async_signal_name = None + self._set_connection_status("disconnected") + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot(popup_error=True) def image( self, - monitor: str | tuple | None = None, - monitor_type: Literal["auto", "1d", "2d"] = "auto", + device_name: str | None = None, + device_entry: str | None = None, color_map: str | None = None, color_bar: Literal["simple", "full"] | None = None, vrange: tuple[int, int] | None = None, @@ -274,30 +536,39 @@ def image( Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ + # Disconnect existing monitor if any + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() - if self.subscriptions["main"].monitor: - self.disconnect_monitor(self.subscriptions["main"].monitor) - if monitor is None or monitor == "": - logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed") + if not device_name or not device_entry: + if device_name or device_entry: + logger.warning("Both device_name and device_entry must be specified") + else: + logger.info("Disconnecting image monitor") + self.device_name = "" return None - if isinstance(monitor, str): - self.entry_validator.validate_monitor(monitor) - elif isinstance(monitor, Sequence): - self.entry_validator.validate_monitor(monitor[0]) - else: - raise ValueError(f"Invalid monitor type: {type(monitor)}") + # Validate device + self.entry_validator.validate_monitor(device_name) + + # Clear old entry first to avoid reconnect attempts on the new device + if self._config.device_entry: + self.device_entry = "" - self.set_image_update(monitor=monitor, type=monitor_type) + # Set properties to trigger connection + self.device_name = device_name + self.device_entry = device_entry + + # Apply visual settings if color_map is not None: self.main_image.color_map = color_map if color_bar is not None: @@ -305,38 +576,91 @@ def image( if vrange is not None: self.vrange = vrange - self._sync_device_selection() - return self.main_image def _sync_device_selection(self): """ - Synchronize the device selection with the current monitor. + Synchronize the device and signal comboboxes with the current monitor state. + This ensures the toolbar reflects the device_name and device_entry properties. """ - config = self.subscriptions["main"] - if config.monitor is not None: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - if isinstance(config.monitor, (list, tuple)): - self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}") - else: - self.device_combo_box.setCurrentText(config.monitor) - self.dim_combo_box.setCurrentText(config.monitor_type) - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) - else: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - self.device_combo_box.setCurrentText("") - self.dim_combo_box.setCurrentText("auto") - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"Image ({self.object_name}) toolbar was not ready during init.") + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + target_device = self._config.device_name or "" + target_entry = self._config.device_entry or "" + + # Check if already synced + if ( + device_selection.device_combo_box.currentText() == target_device + and device_selection.signal_combo_box.currentText() == target_entry + ): + return + + device_selection.set_device_and_signal(target_device, target_entry) + + def _sync_device_entry_from_toolbar(self) -> None: + """ + Pull the signal selection from the toolbar if it differs from the current device_entry. + This keeps CLI-driven device_name updates in sync with the signal combobox state. + """ + if self._device_selection_updating: + return + + if not self._config.device_name: + return + + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + if device_selection.device_combo_box.currentText() != self._config.device_name: + return + + signal_text = device_selection.signal_combo_box.currentText() + if not signal_text or signal_text == self._config.device_entry: + return + + signal_config = device_selection.signal_combo_box.get_signal_config() + if not signal_config: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + signal_config = None + + if not signal_config: + return + + self._signal_configs["main"] = signal_config + self._device_selection_updating = True + try: + self.device_entry = signal_text + finally: + self._device_selection_updating = False + + def _set_connection_status(self, status: str, message: str | None = None) -> None: + self._config.connection_status = status + self._config.connection_error = message + self.property_changed.emit("connection_status", status) + self.property_changed.emit("connection_error", message or "") ################################################################################ # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -353,7 +677,7 @@ def fft(self, enable: bool): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -387,7 +711,7 @@ def num_rotation_90(self, value: int): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. @@ -411,107 +735,183 @@ def transpose(self, enable: bool): ######################################## # Connections - @SafeSlot() - def set_image_update(self, monitor: str | tuple, type: Literal["1d", "2d", "auto"]): + @SafeSlot(dict, dict) + def on_scan_status(self, msg: dict, meta: dict): """ - Set the image update method for the given monitor. + Initial scan status message handler, which is triggered at the beginning and end of scan. + Needed for setup of AsyncSignal connections. Args: - monitor(str): The name of the monitor to use for the image. - type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + msg(dict): The message content. + meta(dict): The message metadata. """ + current_scan_id = msg.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - # TODO consider moving connecting and disconnecting logic to Image itself if multiple images - if isinstance(monitor, (list, tuple)): - device = self.dev[monitor[0]] - signal = monitor[1] - if len(monitor) == 3: - signal_config = monitor[2] - else: - signal_config = device._info["signals"][signal] - signal_class = signal_config.get("signal_class", None) - if signal_class != "PreviewSignal": - logger.warning(f"Signal '{monitor}' is not a PreviewSignal.") - return + @SafeSlot(dict, dict) + def on_scan_progress(self, msg: dict, meta: dict): + """ + For setting async image readback during scan progress updates if widget is started later than scan. - ndim = signal_config.get("describe", None).get("signal_info", None).get("ndim", None) - if ndim is None: - logger.warning( - f"Signal '{monitor}' does not have a valid 'ndim' in its signal_info." - ) - return + Args: + msg(dict): The message content. + meta(dict): The message metadata. + """ + current_scan_id = meta.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - if ndim == 1: - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif ndim == 2: - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" + def _handle_scan_change(self, current_scan_id: str): + """ + Update internal scan ids and refresh async connections if needed. + Also clears image buffers when scan changes. - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - if type == "1d": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif type == "2d": - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" - elif type == "auto": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "auto" - logger.warning( - f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints." - ) - self.subscriptions["main"].monitor_type = "auto" + Args: + current_scan_id (str): The current scan identifier. + """ + if current_scan_id == self.scan_id: + return + + # Scan ID changed - clear buffers and reset image + self.old_scan_id = self.scan_id + self.scan_id = current_scan_id - logger.info(f"Connected to {monitor} with type {type}") - self.subscriptions["main"].monitor = monitor + # Clear image buffer for 1D data accumulation + self.main_image.clear() + if hasattr(self.main_image, "buffer"): + self.main_image.buffer = [] + self.main_image.max_len = 0 + + # Reset crosshair if present + if self.crosshair is not None: + self.crosshair.reset() + + # Reconnect async image subscription with new scan_id + if self.async_update: + self._setup_async_image(scan_id=self.scan_id) - def disconnect_monitor(self, monitor: str | tuple): + def _get_async_signal_name(self) -> tuple[str, str] | None: + """ + Returns device name and async signal name used for endpoints/messages. + + Returns: + tuple[str, str] | None: (device_name, async_signal_name) or None if not available. + """ + if not self._config.device_name or not self._config.device_entry: + return None + + config = self.subscriptions["main"] + async_signal = config.async_signal_name or self._config.device_entry + return self._config.device_name, async_signal + + def _setup_async_image(self, scan_id: str | None): + """ + (Re)connect async image readback for the current scan. + + Args: + scan_id (str | None): The scan identifier to subscribe to. + """ + if not self.async_update: + return + + config = self.subscriptions["main"] + async_names = self._get_async_signal_name() + if async_names is None: + logger.info("Async image setup skipped because monitor information is incomplete.") + return + + device_name, async_signal = async_names + if config.monitor_type == "1d": + slot = self.on_image_update_1d + elif config.monitor_type == "2d": + slot = self.on_image_update_2d + else: + logger.warning( + f"Async image setup skipped due to unsupported monitor type '{config.monitor_type}'." + ) + return + + # Disconnect any previous scan subscriptions to avoid stale updates. + for prev_scan_id in (self.old_scan_id, self.scan_id): + if prev_scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + slot, MessageEndpoints.device_async_signal(prev_scan_id, device_name, async_signal) + ) + + if scan_id is None: + logger.info("Scan ID not available yet; delaying async image subscription.") + return + + self.bec_dispatcher.connect_slot( + slot, + MessageEndpoints.device_async_signal(scan_id, device_name, async_signal), + from_start=True, + cb_info={"scan_id": scan_id}, + ) + logger.info(f"Setup async image for {device_name}.{async_signal} and scan {scan_id}.") + + def disconnect_monitor(self, device_name: str | None = None, device_entry: str | None = None): """ Disconnect the monitor from the image update signals, both 1D and 2D. Args: - monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals. + device_name(str|None): The name of the device to disconnect. Defaults to current device. + device_entry(str|None): The signal/entry name to disconnect. Defaults to current entry. """ - if isinstance(monitor, (list, tuple)): - if self.subscriptions["main"].source == "device_monitor_1d": + config = self.subscriptions["main"] + target_device = device_name or self._config.device_name + target_entry = device_entry or self._config.device_entry + + if not target_device or not target_entry: + logger.warning("Cannot disconnect monitor without both device_name and device_entry") + return + + if self.async_update: + async_signal_name = config.async_signal_name or target_entry + ids_to_check = [self.scan_id, self.old_scan_id] + if config.source == "device_monitor_1d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_async_signal( + scan_id, target_device, async_signal_name + ), + ) + elif config.source == "device_monitor_2d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_async_signal( + scan_id, target_device, async_signal_name + ), + ) + else: + if config.source == "device_monitor_1d": self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1]) + self.on_image_update_1d, + MessageEndpoints.device_preview(target_device, target_entry), ) - elif self.subscriptions["main"].source == "device_monitor_2d": + elif config.source == "device_monitor_2d": self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(monitor[0], monitor[1]) + self.on_image_update_2d, + MessageEndpoints.device_preview(target_device, target_entry), ) else: logger.warning( - f"Cannot disconnect monitor {monitor} with source {self.subscriptions['main'].source}" + f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" ) return - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].monitor = None + + self.subscriptions["main"].async_signal_name = None + self.async_update = False self._sync_device_selection() ######################################## @@ -521,32 +921,37 @@ def disconnect_monitor(self, monitor: str | tuple): def on_image_update_1d(self, msg: dict, metadata: dict): """ Update the image with 1D data. + For preview signals: metadata doesn't contain scan_id. + For async signals: scan_id is managed via on_scan_status/on_scan_progress. Args: msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] - current_scan_id = metadata.get("scan_id", None) + try: + image = self.main_image + except Exception: + return + data = self._get_payload_data(msg) - if current_scan_id is None: + if data is None: + logger.warning("No data received for image update from 1D.") return - if current_scan_id != self.scan_id: - self.scan_id = current_scan_id - self.main_image.clear() - self.main_image.buffer = [] - self.main_image.max_len = 0 - if self.crosshair is not None: - self.crosshair.reset() - image_buffer = self.adjust_image_buffer(self.main_image, data) + + image_buffer = self.adjust_image_buffer(image, data) + if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(image_buffer) + image.set_data(image_buffer) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() - def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray: + @staticmethod + def adjust_image_buffer(image: ImageItem, new_data: np.ndarray) -> np.ndarray: """ Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length. @@ -582,6 +987,7 @@ def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndar ######################################## # 2D updates + @SafeSlot(dict, dict) def on_image_update_2d(self, msg: dict, metadata: dict): """ Update the image with 2D data. @@ -590,14 +996,40 @@ def on_image_update_2d(self, msg: dict, metadata: dict): msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] + try: + image = self.main_image + except Exception: + return + data = self._get_payload_data(msg) + if data is None: + logger.warning("No data received for image update from 2D.") + return if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(data) + image.set_data(data) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() + def _get_payload_data(self, msg: dict) -> np.ndarray | None: + """ + Extract payload from async/preview/monitor1D/2D message structures due to inconsistent formats in backend. + + Args: + msg (dict): The incoming message containing data. + """ + if not self.async_update: + return msg.get("data") + async_names = self._get_async_signal_name() + if async_names is None: + logger.warning("Async payload extraction failed; monitor info incomplete.") + return None + _, async_signal = async_names + return msg.get("signals", {}).get(async_signal, {}).get("value", None) + ################################################################################ # Clean up ################################################################################ @@ -612,28 +1044,33 @@ def _on_layer_removed(self, layer_name: str): """ if layer_name not in self.subscriptions: return - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - config.monitor = None + # For the main layer, disconnect current monitor + if layer_name == "main" and self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) def cleanup(self): """ Disconnect the image update signals and clean up the image. """ self.layer_removed.disconnect(self._on_layer_removed) - for layer_name in list(self.subscriptions.keys()): - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - del self.subscriptions[layer_name] + + # Disconnect current monitor + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self.subscriptions.clear() - # Toolbar cleanup - self.device_combo_box.close() - self.device_combo_box.deleteLater() - self.dim_combo_box.close() - self.dim_combo_box.deleteLater() + # Toolbar cleanup - disconnect the device_selection bundle + try: + self.toolbar.disconnect_bundle("device_selection") + except Exception: # noqa: BLE001 + pass + + self.bec_dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.disconnect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) super().cleanup() @@ -643,6 +1080,7 @@ def cleanup(self): from qtpy.QtWidgets import QApplication, QHBoxLayout app = QApplication(sys.argv) + apply_theme("dark") win = QWidget() win.setWindowTitle("Image Demo") ml = QHBoxLayout(win) diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index 1b638906e..8a0bcaae9 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -9,6 +9,7 @@ from qtpy.QtCore import QPointF, Signal, SignalInstance from qtpy.QtWidgets import QDialog, QVBoxLayout +from bec_widgets.utils import Colors from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.side_panel import SidePanel @@ -131,8 +132,9 @@ def add( image.setZValue(z_position) image.removed.connect(self._remove_destroyed_layer) - # FIXME: For now, we hard-code the default color map here. In the future, this should be configurable. - image.color_map = "plasma" + color_map = getattr(getattr(self.parent, "config", None), "color_map", None) + if color_map: + image.color_map = color_map self.layers[name] = ImageLayer(name=name, image=image, sync=sync) self.plot_item.addItem(image) @@ -249,6 +251,8 @@ class ImageBase(PlotBase): Base class for the Image widget. """ + MAX_TICKS_COLORBAR = 10 + sync_colorbar_with_autorange = Signal() image_updated = Signal() layer_added = Signal(str) @@ -460,18 +464,20 @@ def disable_autorange(): self.setProperty("autorange", False) if style == "simple": - self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map) + cmap = Colors.get_colormap(self.config.color_map) + self._color_bar = pg.ColorBarItem(colorMap=cmap) self._color_bar.setImageItem(self.layer_manager["main"].image) self._color_bar.sigLevelsChangeFinished.connect(disable_autorange) + self.config.color_bar = "simple" elif style == "full": self._color_bar = pg.HistogramLUTItem() self._color_bar.setImageItem(self.layer_manager["main"].image) - self._color_bar.gradient.loadPreset(self.config.color_map) + self.config.color_bar = "full" + self._apply_colormap_to_colorbar(self.config.color_map) self._color_bar.sigLevelsChanged.connect(disable_autorange) self.plot_widget.addItem(self._color_bar, row=0, col=1) - self.config.color_bar = style else: if self._color_bar: self.plot_widget.removeItem(self._color_bar) @@ -484,6 +490,37 @@ def disable_autorange(): if vrange: # should be at the end to disable the autorange if defined self.v_range = vrange + def _apply_colormap_to_colorbar(self, color_map: str) -> None: + if not self._color_bar: + return + + cmap = Colors.get_colormap(color_map) + + if self.config.color_bar == "simple": + self._color_bar.setColorMap(cmap) + return + + if self.config.color_bar != "full": + return + + gradient = getattr(self._color_bar, "gradient", None) + if gradient is None: + return + + positions = np.linspace(0.0, 1.0, self.MAX_TICKS_COLORBAR) + colors = cmap.map(positions, mode="byte") + + colors = np.asarray(colors) + if colors.ndim != 2: + return + if colors.shape[1] == 3: # add alpha + alpha = np.full((colors.shape[0], 1), 255, dtype=colors.dtype) + colors = np.concatenate([colors, alpha], axis=1) + + ticks = [(float(p), tuple(int(x) for x in c)) for p, c in zip(positions, colors)] + state = {"mode": "rgb", "ticks": ticks} + gradient.restoreState(state) + ################################################################################ # Static rois with roi manager @@ -754,11 +791,11 @@ def color_map(self, value: str): layer.image.color_map = value if self._color_bar: - if self.config.color_bar == "simple": - self._color_bar.setColorMap(value) - elif self.config.color_bar == "full": - self._color_bar.gradient.loadPreset(value) - except ValidationError: + self._apply_colormap_to_colorbar(self.config.color_map) + except ValidationError as exc: + logger.warning( + f"Colormap '{value}' is not available; keeping '{self.config.color_map}'. {exc}" + ) return @SafeProperty("QPointF") diff --git a/bec_widgets/widgets/plots/image/image_item.py b/bec_widgets/widgets/plots/image/image_item.py index df1825384..6f24ca3b1 100644 --- a/bec_widgets/widgets/plots/image/image_item.py +++ b/bec_widgets/widgets/plots/image/image_item.py @@ -119,7 +119,8 @@ def color_map(self, value: str): """Set a new color map.""" try: self.config.color_map = value - self.setColorMap(value) + cmap = Colors.get_colormap(self.config.color_map) + self.setColorMap(cmap) except ValidationError: logger.error(f"Invalid colormap '{value}' provided.") diff --git a/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py new file mode 100644 index 000000000..caef8657a --- /dev/null +++ b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py @@ -0,0 +1,255 @@ +from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget + +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection +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 + + +class DeviceSelection(QWidget): + """Device and signal selection widget for image toolbar.""" + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent) + + self.client = client + self.supported_signals = [ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ] + + # Create device combobox with signal class filter + # This will only show devices that have signals matching the supported signal classes + self.device_combo_box = DeviceComboBox( + parent=self, client=self.client, signal_class_filter=self.supported_signals + ) + self.device_combo_box.setToolTip("Select Device") + self.device_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.device_combo_box.lineEdit().setPlaceholderText("Select Device") + + # Configure SignalComboBox to filter by PreviewSignal and supported async signals + # Also filter by ndim (1D and 2D only) for Image widget + self.signal_combo_box = SignalComboBox( + parent=self, + client=self.client, + signal_class_filter=[ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ], + ndim_filter=[1, 2], # Only show 1D and 2D signals for Image widget + store_signal_config=True, + require_device=True, + ) + self.signal_combo_box.setToolTip("Select Signal") + self.signal_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.signal_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.signal_combo_box.lineEdit().setPlaceholderText("Select Signal") + + # Connect comboboxes together + self.device_combo_box.currentTextChanged.connect(self.signal_combo_box.set_device) + self.device_combo_box.device_reset.connect(self.signal_combo_box.reset_selection) + + # Simple horizontal layout with stretch to fill space + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.device_combo_box, stretch=1) + layout.addWidget(self.signal_combo_box, stretch=1) + + def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None: + """Set the displayed device and signal without emitting selection signals.""" + device_name = device_name or "" + device_entry = device_entry or "" + + self.device_combo_box.blockSignals(True) + self.signal_combo_box.blockSignals(True) + + try: + if device_name: + # Set device in device_combo_box + index = self.device_combo_box.findText(device_name) + if index >= 0: + self.device_combo_box.setCurrentIndex(index) + else: + # Device not found in list, but still set it + self.device_combo_box.setCurrentText(device_name) + + # Only update signal combobox device filter if it's actually changing + # This prevents redundant repopulation which can cause duplicates !!!! + current_device = getattr(self.signal_combo_box, "_device", None) + if current_device != device_name: + self.signal_combo_box.set_device(device_name) + + # Sync signal combobox selection + if device_entry: + # Try to find the signal by component_name (which is what's displayed) + found = False + for i in range(self.signal_combo_box.count()): + text = self.signal_combo_box.itemText(i) + config_data = self.signal_combo_box.itemData(i) + + # Check if this matches our signal + if config_data: + component_name = config_data.get("component_name", "") + if text == component_name or text == device_entry: + self.signal_combo_box.setCurrentIndex(i) + found = True + break + + if not found: + # Fallback: try to match the device_entry directly + index = self.signal_combo_box.findText(device_entry) + if index >= 0: + self.signal_combo_box.setCurrentIndex(index) + else: + # No device set, clear selections + self.device_combo_box.setCurrentText("") + self.signal_combo_box.reset_selection() + finally: + # Always unblock signals + self.device_combo_box.blockSignals(False) + self.signal_combo_box.blockSignals(False) + + def set_connection_status(self, status: str, message: str | None = None) -> None: + tooltip = f"Connection status: {status}" + if message: + tooltip = f"{tooltip}\n{message}" + self.device_combo_box.setToolTip(tooltip) + self.signal_combo_box.setToolTip(tooltip) + + if not self.device_combo_box.is_valid_input or not self.signal_combo_box.is_valid_input: + return + + if status == "error": + style = "border: 1px solid orange;" + else: + style = "border: 1px solid transparent;" + + self.device_combo_box.setStyleSheet(style) + self.signal_combo_box.setStyleSheet(style) + + def cleanup(self): + """Clean up the widget resources.""" + self.device_combo_box.close() + self.device_combo_box.deleteLater() + self.signal_combo_box.close() + self.signal_combo_box.deleteLater() + + +def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle: + """ + Creates a device selection toolbar bundle for Image widget. + + Includes a resizable splitter after the device selection. All subsequent bundles' + actions will appear compactly after the splitter with no gaps. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + client: The BEC client instance. + + Returns: + ToolbarBundle: The device selection toolbar bundle. + """ + device_selection_widget = DeviceSelection(parent=components.toolbar, client=client) + components.add_safe( + "device_selection", WidgetAction(widget=device_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("device_selection", components) + bundle.add_action("device_selection") + + bundle.add_splitter( + name="device_selection_splitter", + target_widget=device_selection_widget, + min_width=210, + max_width=600, + ) + + return bundle + + +class DeviceSelectionConnection(BundleConnection): + """ + Connection helper for the device selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "device_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + self.register_property_sync("device_name", self._sync_from_device_name) + self.register_property_sync("device_entry", self._sync_from_device_entry) + self.register_property_sync("connection_status", self._sync_connection_status) + self.register_property_sync("connection_error", self._sync_connection_status) + + def _widget(self) -> DeviceSelection: + return self.components.get_action("device_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.connect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.connect( + self.target_widget.on_device_selection_changed + ) + self.connect_property_sync(self.target_widget) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.disconnect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.disconnect( + self.target_widget.on_device_selection_changed + ) + self.disconnect_property_sync(self.target_widget) + self._connected = False + widget.cleanup() + + def _sync_from_device_name(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + self.target_widget._sync_device_entry_from_toolbar() + + def _sync_from_device_entry(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + + def _sync_connection_status(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_connection_status( + self.target_widget._config.connection_status, + self.target_widget._config.connection_error, + ) diff --git a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py index 7d3579445..6c8d63b6f 100644 --- a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py +++ b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py @@ -300,9 +300,14 @@ def image_processing(components: ToolbarComponents) -> ToolbarBundle: class ImageProcessingConnection(BundleConnection): """ Connection class for the image processing toolbar bundle. + + Provides bidirectional synchronization between toolbar actions and widget properties: + - Toolbar clicks → Update properties + - Property changes → Update toolbar (via property_changed signal) """ def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) self.bundle_name = "image_processing" self.components = components self.target_widget = target_widget @@ -315,7 +320,6 @@ def __init__(self, components: ToolbarComponents, target_widget=None): raise AttributeError( "Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes." ) - super().__init__() self.fft = components.get_action("image_processing_fft") self.log = components.get_action("image_processing_log") self.transpose = components.get_action("image_processing_transpose") @@ -324,6 +328,11 @@ def __init__(self, components: ToolbarComponents, target_widget=None): self.reset = components.get_action("image_processing_reset") self._connected = False + # Register property sync methods for bidirectional sync + self.register_checked_action_sync("fft", self.fft) + self.register_checked_action_sync("log", self.log) + self.register_checked_action_sync("transpose", self.transpose) + @SafeSlot() def toggle_fft(self): checked = self.fft.action.isChecked() @@ -367,8 +376,11 @@ def reset_settings(self): def connect(self): """ Connect the actions to the target widget's methods. + Enables bidirectional sync: toolbar ↔ properties. """ self._connected = True + + # Toolbar → Property connections self.fft.action.triggered.connect(self.toggle_fft) self.log.action.triggered.connect(self.toggle_log) self.transpose.action.triggered.connect(self.toggle_transpose) @@ -376,15 +388,25 @@ def connect(self): self.left.action.triggered.connect(self.rotate_left) self.reset.action.triggered.connect(self.reset_settings) + # Property → Toolbar connections + self.connect_property_sync(self.target_widget) + def disconnect(self): """ Disconnect the actions from the target widget's methods. """ if not self._connected: return + + # Disconnect toolbar → property self.fft.action.triggered.disconnect(self.toggle_fft) self.log.action.triggered.disconnect(self.toggle_log) self.transpose.action.triggered.disconnect(self.toggle_transpose) self.right.action.triggered.disconnect(self.rotate_right) self.left.action.triggered.disconnect(self.rotate_left) self.reset.action.triggered.disconnect(self.reset_settings) + + # Disconnect property → toolbar + self.disconnect_property_sync(self.target_widget) + + self._connected = False diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index 0241ce103..7073bedc4 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -11,13 +11,15 @@ from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from bec_widgets.utils import Colors, ConnectionConfig -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import ( - MotorSelectionAction, + MotorSelection, + MotorSelectionConnection, + motor_selection_bundle, ) from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode @@ -107,6 +109,10 @@ class MotorMap(PlotBase): "map", "reset_history", "get_data", + "x_motor", + "x_motor.setter", + "y_motor", + "y_motor.setter", ] update_signal = Signal() @@ -155,11 +161,10 @@ def _init_motor_map_toolbar(self): """ Initialize the toolbar for the motor map widget. """ - motor_selection = MotorSelectionAction(parent=self) - self.toolbar.add_action("motor_selection", motor_selection) - - motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed) - motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed) + self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self) + ) self.toolbar.components.get_action("reset_legend").action.setVisible(False) @@ -188,12 +193,19 @@ def _init_motor_map_toolbar(self): if self.ui_mode == UIMode.POPUP: bundles.append("axis_popup") self.toolbar.show_bundles(bundles) + self._sync_motor_map_selection_toolbar() @SafeSlot() def on_motor_selection_changed(self, _): - action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection") - motor_x = action.motor_x.currentText() - motor_y = action.motor_y.currentText() + action = self.toolbar.components.get_action("motor_selection") + motor_selection: MotorSelection = action.widget + motor_x = motor_selection.motor_x.currentText() + motor_y = motor_selection.motor_y.currentText() + + if motor_x and not self._validate_motor_name(motor_x): + return + if motor_y and not self._validate_motor_name(motor_y): + return if motor_x != "" and motor_y != "": if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name: @@ -246,6 +258,36 @@ def _motor_map_settings_closed(self): # Widget Specific Properties ################################################################################ + @SafeProperty(str) + def x_motor(self) -> str: + """Name of the motor shown on the X axis.""" + return self.config.x_motor.name or "" + + @x_motor.setter + def x_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.x_motor.name or ""): + return + if motor_name and self.y_motor: + self.map(motor_name, self.y_motor, suppress_errors=True) + return + self._set_motor_name(axis="x", motor_name=motor_name) + + @SafeProperty(str) + def y_motor(self) -> str: + """Name of the motor shown on the Y axis.""" + return self.config.y_motor.name or "" + + @y_motor.setter + def y_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.y_motor.name or ""): + return + if motor_name and self.x_motor: + self.map(self.x_motor, motor_name, suppress_errors=True) + return + self._set_motor_name(axis="y", motor_name=motor_name) + # color_scatter for designer, color for CLI to not bother users with QColor @SafeProperty("QColor") def color_scatter(self) -> QtGui.QColor: @@ -387,11 +429,47 @@ def scatter_size(self, scatter_size: int) -> None: self.update_signal.emit() self.property_changed.emit("scatter_size", scatter_size) + def _validate_motor_name(self, motor_name: str) -> bool: + """ + Check motor validity against BEC without raising. + + Args: + motor_name(str): Name of the motor to validate. + + Returns: + bool: True if motor is valid, False otherwise. + """ + if not motor_name: + return False + try: + self.entry_validator.validate_signal(motor_name, None) + return True + except Exception: # noqa: BLE001 - validator can raise multiple error types + return False + + def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None: + """ + Update stored motor name for given axis and optionally refresh the toolbar selection. + """ + motor_name = motor_name or "" + motor_config = self.config.x_motor if axis == "x" else self.config.y_motor + + if motor_config.name == motor_name: + return + + motor_config.name = motor_name + self.property_changed.emit(f"{axis}_motor", motor_name) + + if sync_toolbar: + self._sync_motor_map_selection_toolbar() + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot() - def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: + def map( + self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False + ) -> None: """ Set the x and y motor names. @@ -399,15 +477,23 @@ def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ self.plot_item.clear() if validate_bec: - self.entry_validator.validate_signal(x_name, None) - self.entry_validator.validate_signal(y_name, None) - - self.config.x_motor.name = x_name - self.config.y_motor.name = y_name + if suppress_errors: + try: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + except Exception: + return + else: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + + self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False) + self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False) motor_x_limit = self._get_motor_limit(self.config.x_motor.name) motor_y_limit = self._get_motor_limit(self.config.y_motor.name) @@ -734,21 +820,24 @@ def _sync_motor_map_selection_toolbar(self): """ Sync the motor map selection toolbar with the current motor map. """ - motor_selection = self.toolbar.components.get_action("motor_selection") - - motor_x = motor_selection.motor_x.currentText() - motor_y = motor_selection.motor_y.currentText() + try: + motor_selection_action = self.toolbar.components.get_action("motor_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.") + return + if motor_selection_action is None: + return + motor_selection: MotorSelection = motor_selection_action.widget + target_x = self.config.x_motor.name or "" + target_y = self.config.y_motor.name or "" + + if ( + motor_selection.motor_x.currentText() == target_x + and motor_selection.motor_y.currentText() == target_y + ): + return - if motor_x != self.config.x_motor.name: - motor_selection.motor_x.blockSignals(True) - motor_selection.motor_x.set_device(self.config.x_motor.name) - motor_selection.motor_x.check_validity(self.config.x_motor.name) - motor_selection.motor_x.blockSignals(False) - if motor_y != self.config.y_motor.name: - motor_selection.motor_y.blockSignals(True) - motor_selection.motor_y.set_device(self.config.y_motor.name) - motor_selection.motor_y.check_validity(self.config.y_motor.name) - motor_selection.motor_y.blockSignals(False) + motor_selection.set_motors(target_x, target_y) ################################################################################ # Export Methods @@ -790,7 +879,7 @@ def __init__(self): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index a37c3f210..ecd41627c 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -1,43 +1,61 @@ -from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget +from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection 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 -class MotorSelectionAction(ToolBarAction): +class MotorSelection(QWidget): + """Motor selection widget for MotorMap toolbar.""" + def __init__(self, parent=None): - super().__init__(icon_path=None, tooltip=None, checkable=False) - self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + super().__init__(parent=parent) + + self.motor_x = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_x.addItem("", None) self.motor_x.setCurrentText("") self.motor_x.setToolTip("Select Motor X") self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) - self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_x.setEditable(True) + self.motor_x.setMinimumWidth(60) + + self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_y.addItem("", None) self.motor_y.setCurrentText("") self.motor_y.setToolTip("Select Motor Y") self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) + self.motor_y.setEditable(True) + self.motor_y.setMinimumWidth(60) - self.container = QWidget(parent) - layout = QHBoxLayout(self.container) + # Simple horizontal layout with stretch to fill space + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self.motor_x) - layout.addWidget(self.motor_y) - self.container.setLayout(layout) - self.action = self.container + layout.setSpacing(2) + layout.addWidget(self.motor_x, stretch=1) # Equal stretch + layout.addWidget(self.motor_y, stretch=1) # Equal stretch - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the widget to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the widget to. - target (QWidget): The target widget for the action. - """ - - toolbar.addWidget(self.container) + def set_motors(self, motor_x: str | None, motor_y: str | None) -> None: + """Set the displayed motors without emitting selection signals.""" + motor_x = motor_x or "" + motor_y = motor_y or "" + self.motor_x.blockSignals(True) + self.motor_y.blockSignals(True) + try: + if motor_x: + self.motor_x.set_device(motor_x) + self.motor_x.check_validity(motor_x) + else: + self.motor_x.setCurrentText("") + if motor_y: + self.motor_y.set_device(motor_y) + self.motor_y.check_validity(motor_y) + else: + self.motor_y.setCurrentText("") + finally: + self.motor_x.blockSignals(False) + self.motor_y.blockSignals(False) def cleanup(self): """ @@ -47,5 +65,68 @@ def cleanup(self): self.motor_x.deleteLater() self.motor_y.close() self.motor_y.deleteLater() - self.container.close() - self.container.deleteLater() + + +def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for MotorMap. + + Includes a resizable splitter after the motor selection. All subsequent bundles' + actions will appear compactly after the splitter with no gaps. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + + motor_selection_widget = MotorSelection(parent=components.toolbar) + components.add_safe( + "motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("motor_selection", components) + bundle.add_action("motor_selection") + + bundle.add_splitter( + name="motor_selection_splitter", + target_widget=motor_selection_widget, + min_width=170, + max_width=400, + ) + + return bundle + + +class MotorSelectionConnection(BundleConnection): + """ + Connection helper for the motor selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "motor_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + + def _widget(self) -> MotorSelection: + return self.components.get_action("motor_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + self._connected = False + widget.cleanup() diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index ea8bcf075..88c234829 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -173,13 +173,19 @@ def __init__( self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item) + # Visibility States + self._toolbar_visible = True + self._enable_fps_monitor = False + self._outer_axes_visible = self.plot_item.getAxis("top").isVisible() + self._inner_axes_visible = self.plot_item.getAxis("bottom").isVisible() + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") self._init_toolbar() self._init_ui() self._connect_to_theme_change() - self._update_theme() + self._update_theme(None) def apply_theme(self, theme: str): self.round_plot_widget.apply_theme(theme) @@ -187,6 +193,8 @@ def apply_theme(self, theme: str): def _init_ui(self): self.layout.addWidget(self.layout_manager) self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget) + self.round_plot_widget.setProperty("variant", "plot_background") + self.round_plot_widget.setProperty("frameless", True) self.layout_manager.add_widget(self.round_plot_widget) self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top") @@ -336,7 +344,7 @@ def enable_toolbar(self) -> bool: """ Show Toolbar. """ - return self.toolbar.isVisible() + return self._toolbar_visible @enable_toolbar.setter def enable_toolbar(self, value: bool): @@ -346,6 +354,7 @@ def enable_toolbar(self, value: bool): Args: value(bool): The value to set. """ + self._toolbar_visible = value self.toolbar.setVisible(value) @SafeProperty(bool, doc="Enable the FPS monitor.") @@ -353,7 +362,7 @@ def enable_fps_monitor(self) -> bool: """ Enable the FPS monitor. """ - return self.fps_label.isVisible() + return self._enable_fps_monitor @enable_fps_monitor.setter def enable_fps_monitor(self, value: bool): @@ -363,9 +372,11 @@ def enable_fps_monitor(self, value: bool): Args: value(bool): The value to set. """ - if value and self.fps_monitor is None: + if value == self._enable_fps_monitor: + return + if value: self.hook_fps_monitor() - elif not value and self.fps_monitor is not None: + else: self.unhook_fps_monitor() ################################################################################ @@ -435,7 +446,7 @@ def set(self, **kwargs): else: logger.warning(f"Property {key} not found.") - @SafeProperty(str, doc="The title of the axes.") + @SafeProperty(str, auto_emit=True, doc="The title of the axes.") def title(self) -> str: """ Set title of the plot. @@ -451,9 +462,8 @@ def title(self, value: str): value(str): The title to set. """ self.plot_item.setTitle(value) - self.property_changed.emit("title", value) - @SafeProperty(str, doc="The text of the x label") + @SafeProperty(str, auto_emit=True, doc="The text of the x label") def x_label(self) -> str: """ The set label for the x-axis. @@ -470,7 +480,6 @@ def x_label(self, value: str): """ self._user_x_label = value self._apply_x_label() - self.property_changed.emit("x_label", self._user_x_label) @property def x_label_suffix(self) -> str: @@ -524,7 +533,7 @@ def _apply_x_label(self): if self.plot_item.getAxis("bottom").isVisible(): self.plot_item.setLabel("bottom", text=final_label) - @SafeProperty(str, doc="The text of the y label") + @SafeProperty(str, auto_emit=True, doc="The text of the y label") def y_label(self) -> str: """ The set label for the y-axis. @@ -540,7 +549,6 @@ def y_label(self, value: str): """ self._user_y_label = value self._apply_y_label() - self.property_changed.emit("y_label", value) @property def y_label_suffix(self) -> str: @@ -761,7 +769,7 @@ def y_max(self, value: float): """ self.y_limits = (self.y_lim[0], value) - @SafeProperty(bool, doc="Show grid on the x-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the x-axis.") def x_grid(self) -> bool: """ Show grid on the x-axis. @@ -777,9 +785,8 @@ def x_grid(self, value: bool): value(bool): The value to set. """ self.plot_item.showGrid(x=value) - self.property_changed.emit("x_grid", value) - @SafeProperty(bool, doc="Show grid on the y-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the y-axis.") def y_grid(self) -> bool: """ Show grid on the y-axis. @@ -795,9 +802,8 @@ def y_grid(self, value: bool): value(bool): The value to set. """ self.plot_item.showGrid(y=value) - self.property_changed.emit("y_grid", value) - @SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set X-axis to log scale if True, linear if False.") def x_log(self) -> bool: """ Set X-axis to log scale if True, linear if False. @@ -813,9 +819,8 @@ def x_log(self, value: bool): value(bool): The value to set. """ self.plot_item.setLogMode(x=value) - self.property_changed.emit("x_log", value) - @SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set Y-axis to log scale if True, linear if False.") def y_log(self) -> bool: """ Set Y-axis to log scale if True, linear if False. @@ -831,14 +836,13 @@ def y_log(self, value: bool): value(bool): The value to set. """ self.plot_item.setLogMode(y=value) - self.property_changed.emit("y_log", value) - @SafeProperty(bool, doc="Show the outer axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show the outer axes of the plot widget.") def outer_axes(self) -> bool: """ Show the outer axes of the plot widget. """ - return self.plot_item.getAxis("top").isVisible() + return self._outer_axes_visible @outer_axes.setter def outer_axes(self, value: bool): @@ -851,14 +855,14 @@ def outer_axes(self, value: bool): self.plot_item.showAxis("top", value) self.plot_item.showAxis("right", value) - self.property_changed.emit("outer_axes", value) + self._outer_axes_visible = value - @SafeProperty(bool, doc="Show inner axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show inner axes of the plot widget.") def inner_axes(self) -> bool: """ Show inner axes of the plot widget. """ - return self.plot_item.getAxis("bottom").isVisible() + return self._inner_axes_visible @inner_axes.setter def inner_axes(self, value: bool): @@ -871,9 +875,9 @@ def inner_axes(self, value: bool): self.plot_item.showAxis("bottom", value) self.plot_item.showAxis("left", value) + self._inner_axes_visible = value self._apply_x_label() self._apply_y_label() - self.property_changed.emit("inner_axes", value) @SafeProperty(bool, doc="Invert X axis.") def invert_x(self) -> bool: @@ -1045,6 +1049,7 @@ def hook_fps_monitor(self): self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label) self.update_fps_label(0) + self._enable_fps_monitor = True def unhook_fps_monitor(self, delete_label=True): """Unhook the FPS monitor from the plot.""" @@ -1056,6 +1061,7 @@ def unhook_fps_monitor(self, delete_label=True): if self.fps_label is not None: # Hide Label self.fps_label.hide() + self._enable_fps_monitor = False ################################################################################ # Crosshair @@ -1095,7 +1101,9 @@ def toggle_crosshair(self) -> None: self.unhook_crosshair() @SafeProperty( - int, doc="Minimum decimal places for crosshair when dynamic precision is enabled." + int, + auto_emit=True, + doc="Minimum decimal places for crosshair when dynamic precision is enabled.", ) def minimal_crosshair_precision(self) -> int: """ @@ -1115,7 +1123,6 @@ def minimal_crosshair_precision(self, value: int): self._minimal_crosshair_precision = value_int if self.crosshair is not None: self.crosshair.min_precision = value_int - self.property_changed.emit("minimal_crosshair_precision", value_int) @SafeSlot() def reset(self) -> None: diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 3ed5ea653..48a20f147 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -1,7 +1,5 @@ from __future__ import annotations -import json - import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -10,7 +8,6 @@ from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from bec_widgets.utils import Colors, ConnectionConfig -from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbars.toolbar import MaterialIconAction @@ -46,12 +43,24 @@ class ScatterWaveform(PlotBase): USER_ACCESS = [ *PlotBase.USER_ACCESS, # Scatter Waveform Specific RPC Access - "main_curve", "color_map", "color_map.setter", "plot", "update_with_scan_history", "clear_all", + # Device properties + "x_device_name", + "x_device_name.setter", + "x_device_entry", + "x_device_entry.setter", + "y_device_name", + "y_device_name.setter", + "y_device_entry", + "y_device_entry.setter", + "z_device_name", + "z_device_name.setter", + "z_device_entry", + "z_device_entry.setter", ] sync_signal_update = Signal() @@ -94,6 +103,13 @@ def __init__( ) self._init_scatter_curve_settings() + + # Show toolbar bundles - only include scatter_waveform_settings if not in SIDE mode + shown_bundles = ["plot_export", "mouse_interaction", "roi", "axis_popup"] + if self.ui_mode != UIMode.SIDE: + shown_bundles.insert(0, "scatter_waveform_settings") + self.toolbar.show_bundles(shown_bundles) + self.update_with_scan_history(-1) ################################################################################ @@ -122,15 +138,9 @@ def _init_scatter_curve_settings(self): checkable=True, parent=self, ) - self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action) - self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings") + self.toolbar.add_action("scatter_waveform_settings", scatter_curve_action) scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings) - shown_bundles = self.toolbar.shown_bundles - if "performance" in shown_bundles: - shown_bundles.remove("performance") - self.toolbar.show_bundles(shown_bundles) - def show_scatter_curve_settings(self): """ Show the scatter curve settings dialog. @@ -146,7 +156,7 @@ def show_scatter_curve_settings(self): window_title="Scatter Curve Settings", modal=False, ) - self.scatter_dialog.resize(620, 200) + self.scatter_dialog.resize(700, 240) # When the dialog is closed, update the toolbar icon and clear the reference self.scatter_dialog.finished.connect(self._scatter_dialog_closed) self.scatter_dialog.show() @@ -192,27 +202,6 @@ def color_map(self, value: str): except ValidationError: return - @SafeProperty(str, designable=False, popup_error=True) - def curve_json(self) -> str: - """ - Get the curve configuration as a JSON string. - """ - return json.dumps(self.main_curve.config.model_dump(), indent=2) - - @curve_json.setter - def curve_json(self, value: str): - """ - Set the curve configuration from a JSON string. - - Args: - value(str): The JSON string to set the curve configuration from. - """ - try: - config = ScatterCurveConfig(**json.loads(value)) - self._add_main_scatter_curve(config) - except json.JSONDecodeError as e: - logger.error(f"Failed to decode JSON: {e}") - ################################################################################ # High Level methods for API ################################################################################ @@ -286,10 +275,6 @@ def _add_main_scatter_curve(self, config: ScatterCurveConfig): Args: config(ScatterCurveConfig): The configuration of the scatter curve. """ - # Apply suffix for axes - self.set_x_label_suffix(f"[{config.x_device.name}-{config.x_device.name}]") - self.set_y_label_suffix(f"[{config.y_device.name}-{config.y_device.name}]") - # To have only one main curve if self._main_curve is not None: self.rpc_register.remove_rpc(self._main_curve) @@ -299,6 +284,9 @@ def _add_main_scatter_curve(self, config: ScatterCurveConfig): self._main_curve = None self._main_curve = ScatterCurve(parent_item=self, config=config, name=config.label) + + # Update axis labels (matching Heatmap's label policy) + self.update_labels() self.plot_item.addItem(self._main_curve) self.sync_signal_update.emit() @@ -406,6 +394,284 @@ def _fetch_scan_data_and_access(self): scan_devices = self.scan_item.devices return scan_devices, "value" + ################################################################################ + # Widget Specific Properties + ################################################################################ + + @SafeProperty(str) + def x_device_name(self) -> str: + """Device name for the X axis.""" + if self._main_curve is None or self._main_curve.config.x_device is None: + return "" + return self._main_curve.config.x_device.name or "" + + @x_device_name.setter + def x_device_name(self, device_name: str) -> None: + """ + Set the X device name. + + Args: + device_name(str): Device name for the X axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.x_device is None: + self._main_curve.config.x_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.x_device.name = device_name + self._main_curve.config.x_device.entry = entry + self.property_changed.emit("x_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.x_device is not None: + self._main_curve.config.x_device = None + self.property_changed.emit("x_device_name", "") + self.update_labels() + + @SafeProperty(str) + def x_device_entry(self) -> str: + """Signal entry for the X axis device.""" + if self._main_curve is None or self._main_curve.config.x_device is None: + return "" + return self._main_curve.config.x_device.entry or "" + + @x_device_entry.setter + def x_device_entry(self, entry: str) -> None: + """ + Set the X device entry. + + Args: + entry(str): Signal entry for the X axis device + """ + if not entry: + return + + if self._main_curve.config.x_device is None: + logger.warning("Cannot set x_device_entry without x_device_name set first.") + return + + device_name = self._main_curve.config.x_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.x_device.entry = validated_entry + self.property_changed.emit("x_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def y_device_name(self) -> str: + """Device name for the Y axis.""" + if self._main_curve is None or self._main_curve.config.y_device is None: + return "" + return self._main_curve.config.y_device.name or "" + + @y_device_name.setter + def y_device_name(self, device_name: str) -> None: + """ + Set the Y device name. + + Args: + device_name(str): Device name for the Y axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.y_device is None: + self._main_curve.config.y_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.y_device.name = device_name + self._main_curve.config.y_device.entry = entry + self.property_changed.emit("y_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.y_device is not None: + self._main_curve.config.y_device = None + self.property_changed.emit("y_device_name", "") + self.update_labels() + + @SafeProperty(str) + def y_device_entry(self) -> str: + """Signal entry for the Y axis device.""" + if self._main_curve is None or self._main_curve.config.y_device is None: + return "" + return self._main_curve.config.y_device.entry or "" + + @y_device_entry.setter + def y_device_entry(self, entry: str) -> None: + """ + Set the Y device entry. + + Args: + entry(str): Signal entry for the Y axis device + """ + if not entry: + return + + if self._main_curve.config.y_device is None: + logger.warning("Cannot set y_device_entry without y_device_name set first.") + return + + device_name = self._main_curve.config.y_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.y_device.entry = validated_entry + self.property_changed.emit("y_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def z_device_name(self) -> str: + """Device name for the Z (color) axis.""" + if self._main_curve is None or self._main_curve.config.z_device is None: + return "" + return self._main_curve.config.z_device.name or "" + + @z_device_name.setter + def z_device_name(self, device_name: str) -> None: + """ + Set the Z device name. + + Args: + device_name(str): Device name for the Z axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.z_device is None: + self._main_curve.config.z_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.z_device.name = device_name + self._main_curve.config.z_device.entry = entry + self.property_changed.emit("z_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.z_device is not None: + self._main_curve.config.z_device = None + self.property_changed.emit("z_device_name", "") + self.update_labels() + + @SafeProperty(str) + def z_device_entry(self) -> str: + """Signal entry for the Z (color) axis device.""" + if self._main_curve is None or self._main_curve.config.z_device is None: + return "" + return self._main_curve.config.z_device.entry or "" + + @z_device_entry.setter + def z_device_entry(self, entry: str) -> None: + """ + Set the Z device entry. + + Args: + entry(str): Signal entry for the Z axis device + """ + if not entry: + return + + if self._main_curve.config.z_device is None: + logger.warning("Cannot set z_device_entry without z_device_name set first.") + return + + device_name = self._main_curve.config.z_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.z_device.entry = validated_entry + self.property_changed.emit("z_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + def _try_auto_plot(self) -> None: + """ + Attempt to automatically call plot() if all three devices are set. + """ + has_x = self._main_curve.config.x_device is not None + has_y = self._main_curve.config.y_device is not None + has_z = self._main_curve.config.z_device is not None + + if has_x and has_y and has_z: + x_name = self._main_curve.config.x_device.name + x_entry = self._main_curve.config.x_device.entry + y_name = self._main_curve.config.y_device.name + y_entry = self._main_curve.config.y_device.entry + z_name = self._main_curve.config.z_device.name + z_entry = self._main_curve.config.z_device.entry + try: + self.plot( + x_name=x_name, + y_name=y_name, + z_name=z_name, + x_entry=x_entry, + y_entry=y_entry, + z_entry=z_entry, + validate_bec=False, # Don't validate - entries already validated + ) + except Exception as e: + logger.debug(f"Auto-plot failed: {e}") + pass # Silently fail if plot cannot be called yet + + def update_labels(self): + """ + Update the labels of the x and y axes based on current device configuration. + """ + if self._main_curve is None: + return + + config = self._main_curve.config + + # Safely get device names + x_device = config.x_device + y_device = config.y_device + + x_name = x_device.name if x_device else None + y_name = y_device.name if y_device else None + + if x_name is not None: + self.x_label = x_name # type: ignore + x_dev = self.dev.get(x_name) + if x_dev and hasattr(x_dev, "egu"): + self.x_label_units = x_dev.egu() + + if y_name is not None: + self.y_label = y_name # type: ignore + y_dev = self.dev.get(y_name) + if y_dev and hasattr(y_dev, "egu"): + self.y_label_units = y_dev.egu() + + ################################################################################ + # Scan History + ################################################################################ + @SafeSlot(int) @SafeSlot(str) @SafeSlot() @@ -504,8 +770,10 @@ def __init__(self): from qtpy.QtWidgets import QApplication + from bec_widgets.utils.colors import apply_theme + app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py index 703266af0..475181c51 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py @@ -86,29 +86,29 @@ def fetch_all_properties(self): if hasattr(self.ui, "x_name"): self.ui.x_name.set_device(x_name) if hasattr(self.ui, "x_entry") and x_entry is not None: - self.ui.x_entry.setText(x_entry) + self.ui.x_entry.set_to_obj_name(x_entry) if hasattr(self.ui, "y_name"): self.ui.y_name.set_device(y_name) if hasattr(self.ui, "y_entry") and y_entry is not None: - self.ui.y_entry.setText(y_entry) + self.ui.y_entry.set_to_obj_name(y_entry) if hasattr(self.ui, "z_name"): self.ui.z_name.set_device(z_name) if hasattr(self.ui, "z_entry") and z_entry is not None: - self.ui.z_entry.setText(z_entry) + self.ui.z_entry.set_to_obj_name(z_entry) @SafeSlot() def accept_changes(self): """ Apply all properties from the settings widget to the target widget. """ - x_name = self.ui.x_name.text() - x_entry = self.ui.x_entry.text() - y_name = self.ui.y_name.text() - y_entry = self.ui.y_entry.text() - z_name = self.ui.z_name.text() - z_entry = self.ui.z_entry.text() + x_name = self.ui.x_name.currentText() + x_entry = self.ui.x_entry.get_signal_name() + y_name = self.ui.y_name.currentText() + y_entry = self.ui.y_entry.get_signal_name() + z_name = self.ui.z_name.currentText() + z_entry = self.ui.z_entry.get_signal_name() validate_bec = self.ui.validate_bec.checked color_map = self.ui.color_map.colormap diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui index a61d20241..c8527291c 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui @@ -6,8 +6,8 @@ 0 0 - 604 - 166 + 826 + 204 @@ -31,6 +31,13 @@ + + + + Qt::Orientation::Horizontal + + + @@ -46,9 +53,6 @@ - - - @@ -56,8 +60,22 @@ + + + + true + + + true + + + - + + + true + + @@ -75,9 +93,6 @@ - - - @@ -85,8 +100,22 @@ + + + + true + + + true + + + - + + + true + + @@ -111,11 +140,22 @@ - - - - + + + true + + + true + + + + + + + true + + @@ -125,77 +165,130 @@ - - DeviceLineEdit - QLineEdit -
device_line_edit
-
ToggleSwitch - QWidget +
toggle_switch
BECColorMapWidget - QWidget +
bec_color_map_widget
+ + DeviceComboBox + +
device_combo_box
+
+ + SignalComboBox + +
signal_combo_box
+
x_name - x_entry y_name - y_entry z_name + x_entry + y_entry z_entry x_name - textChanged(QString) + device_reset() x_entry - clear() + reset_selection() - 134 - 95 + 136 + 122 - 138 - 128 + 133 + 151 y_name - textChanged(QString) + device_reset() y_entry - clear() + reset_selection() - 351 - 91 + 412 + 122 - 349 + 409 + 151 + + + + + z_name + device_reset() + z_entry + reset_selection() + + + 687 121 + + 684 + 149 + + + + + x_name + currentTextChanged(QString) + x_entry + set_device(QString) + + + 152 + 123 + + + 151 + 151 + + + + + y_name + currentTextChanged(QString) + y_entry + set_device(QString) + + + 412 + 121 + + + 409 + 149 + z_name - textChanged(QString) + currentTextChanged(QString) z_entry - clear() + set_device(QString) - 520 - 98 + 687 + 121 - 522 - 127 + 684 + 149 diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index a5951cf31..ac4469799 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -6,6 +6,7 @@ from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtGui import QValidator +from qtpy.QtWidgets import QApplication class ScanIndexValidator(QValidator): @@ -34,6 +35,7 @@ def validate(self, input_str: str, pos: int): from qtpy.QtWidgets import ( + QApplication, QComboBox, QHBoxLayout, QHeaderView, @@ -97,6 +99,7 @@ def __init__( # A top-level device row. super().__init__(tree) + self.app = QApplication.instance() self.tree = tree self.parent_item = parent_item self.curve_tree = tree.parent() # The CurveTree widget @@ -194,7 +197,16 @@ def _init_actions(self): # If device row, add "Add DAP" button if self.source in ("device", "history"): - self.add_dap_button = QPushButton("DAP") + self.add_dap_button = QToolButton() + analysis_icon = material_icon( + "monitoring", + size=(20, 20), + convert_to_pixmap=False, + filled=False, + color=self.app.theme.colors["FG"].toTuple(), + ) + self.add_dap_button.setIcon(analysis_icon) + self.add_dap_button.setToolTip("Add DAP") self.add_dap_button.clicked.connect(lambda: self.add_dap_row()) actions_layout.addWidget(self.add_dap_button) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 3387320bc..51223ebd7 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -26,7 +26,7 @@ from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_signal_proxy import BECSignalProxy -from bec_widgets.utils.colors import Colors, set_theme +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog @@ -2384,7 +2384,7 @@ def _populate_custom_curve_demo(self): import sys app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py index c486ac311..2e758e226 100644 --- a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +++ b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py @@ -82,7 +82,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # Color settings self._background_color = QColor(30, 30, 30) - self._progress_color = accent_colors.highlight # QColor(210, 55, 130) + self._progress_color = accent_colors.highlight self._completed_color = accent_colors.success self._border_color = QColor(50, 50, 50) @@ -91,7 +91,6 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # Progress‑bar state handling self._state = ProgressState.NORMAL - # self._state_colors = dict(PROGRESS_STATE_COLORS) self._state_colors = { ProgressState.NORMAL: accent_colors.default, @@ -109,8 +108,8 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # label on top of the progress bar self.center_label = QLabel(self) self.center_label.setAlignment(Qt.AlignHCenter) - self.center_label.setStyleSheet("color: white;") self.center_label.setMinimumSize(0, 0) + self.center_label.setStyleSheet("background: transparent; color: white;") layout = QVBoxLayout(self) layout.setContentsMargins(10, 0, 10, 0) diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/__init__.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py new file mode 100644 index 000000000..a58398249 --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -0,0 +1,150 @@ +"""Module for a ProgressBar for device initialization progress.""" + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import DeviceInitializationProgressMessage +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar + + +class DeviceInitializationProgressBar(BECWidget, QWidget): + """A progress bar that displays the progress of device initialization.""" + + # Signal emitted for failed device initializations + failed_devices_changed = Signal(list) + + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self._failed_devices: list[str] = [] + + # Main Layout with Group Box + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(0) + self.group_box = QGroupBox(self) + self.group_box.setTitle("Config Update Progress") + main_layout.addWidget(self.group_box) + lay = QVBoxLayout(self.group_box) + lay.setContentsMargins(25, 25, 25, 25) + lay.setSpacing(5) + + # Progress Bar and Label in Layout + self.progress_bar = BECProgressBar(parent=parent, client=client, **kwargs) + self.progress_bar.label_template = "$value / $maximum - $percentage %" + self.progress_label = QLabel("Initializing devices...", self) + + content_layout = QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(0) + content_layout.addWidget(self.progress_bar) + + # Layout for label, to place label properly below progress bar + # Adjust 10px left margin for aesthetic alignment + hor_layout = QHBoxLayout() + hor_layout.setContentsMargins(12, 0, 0, 0) + hor_layout.addWidget(self.progress_label) + content_layout.addLayout(hor_layout) + + # Add content layout to main layout + lay.addLayout(content_layout) + + self.bec_dispatcher.connect_slot( + slot=self._update_device_initialization_progress, + topics=MessageEndpoints.device_initialization_progress(), + ) + self._reset_progress_bar() + + @SafeProperty(list) + def failed_devices(self) -> list[str]: + """Get the list of devices that failed to initialize. + + Returns: + list[str]: A list of device identifiers that failed during initialization. + """ + return self._failed_devices + + @failed_devices.setter + def failed_devices(self, value: list[str]) -> None: + self._failed_devices = value + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot() + def reset_failed_devices(self) -> None: + """Reset the list of failed devices.""" + self._failed_devices.clear() + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot(str) + def add_failed_device(self, device: str) -> None: + """Add a device to the list of failed devices. + + Args: + device (str): The identifier of the device that failed to initialize. + """ + self._failed_devices.append(device) + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot(dict, dict) + def _update_device_initialization_progress(self, msg: dict, metadata: dict) -> None: + """Update the progress bar based on device initialization progress messages. + + Args: + msg (dict): The device initialization progress message. + metadata (dict): Additional metadata about the message. + """ + msg: DeviceInitializationProgressMessage = ( + DeviceInitializationProgressMessage.model_validate(msg) + ) + # Reset progress bar if index has gone backwards, this indicates a new initialization sequence + old_value = self.progress_bar._user_value + if msg.index < old_value: + self._reset_progress_bar() + # Update progress based on message content + if msg.finished is False: + self.progress_label.setText(f"{msg.device} initialization in progress...") + elif msg.finished is True and msg.success is False: + self.add_failed_device(msg.device) + self.progress_label.setText(f"{msg.device} initialization failed!") + else: + self.progress_label.setText(f"{msg.device} initialization succeeded!") + self.progress_bar.set_maximum(msg.total) + self.progress_bar.set_value(msg.index) + self._update_tool_tip() + + def _reset_progress_bar(self) -> None: + """Reset the progress bar to its initial state.""" + self.progress_bar.set_value(0) + self.progress_bar.set_maximum(100) + self.reset_failed_devices() + self._update_tool_tip() + + def _update_tool_tip(self) -> None: + """Update the tooltip to show failed devices if any.""" + if self._failed_devices: + failed_devices_str = ", ".join(sorted(self._failed_devices)) + self.setToolTip(f"Failed devices: {failed_devices_str}") + else: + self.setToolTip("No device initialization failures.") + + +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("dark") + + progressBar = DeviceInitializationProgressBar() + + def my_cb(devices: list): + print("Failed devices:", devices) + + progressBar.failed_devices_changed.connect(my_cb) + progressBar.show() + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject new file mode 100644 index 000000000..2f908ecbe --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject @@ -0,0 +1 @@ +{'files': ['device_initialization_progress_bar.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py similarity index 59% rename from bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py rename to bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py index 4614210bc..52ceea461 100644 --- a/bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py @@ -5,17 +5,19 @@ from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) DOM_XML = """ - + """ -class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None @@ -23,20 +25,20 @@ def __init__(self): def createWidget(self, parent): if parent is None: return QWidget() - t = VSCodeEditor(parent) + t = DeviceInitializationProgressBar(parent) return t def domXml(self): return DOM_XML def group(self): - return "BEC Developer" + return "" def icon(self): - return designer_material_icon(VSCodeEditor.ICON_NAME) + return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME) def includeFile(self): - return "vs_code_editor" + return "device_initialization_progress_bar" def initialize(self, form_editor): self._form_editor = form_editor @@ -48,10 +50,10 @@ def isInitialized(self): return self._form_editor is not None def name(self): - return "VSCodeEditor" + return "DeviceInitializationProgressBar" def toolTip(self): - return "" + return "A progress bar that displays the progress of device initialization." def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/editors/vscode/register_vs_code_editor.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py similarity index 53% rename from bec_widgets/widgets/editors/vscode/register_vs_code_editor.py rename to bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py index 06cbcce44..1f60596ce 100644 --- a/bec_widgets/widgets/editors/vscode/register_vs_code_editor.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py @@ -6,9 +6,11 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.editors.vscode.vs_code_editor_plugin import VSCodeEditorPlugin + from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import ( + DeviceInitializationProgressBarPlugin, + ) - QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/progress/ring_progress_bar/__init__.py b/bec_widgets/widgets/progress/ring_progress_bar/__init__.py index c20ea5599..e69de29bb 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/__init__.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/__init__.py @@ -1 +0,0 @@ -from .ring_progress_bar import RingProgressBar diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index 19b468112..a29641544 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -1,130 +1,88 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import TYPE_CHECKING, Callable, Literal from bec_lib.endpoints import EndpointInfo, MessageEndpoints -from pydantic import BaseModel, Field, field_validator -from pydantic_core import PydanticCustomError -from qtpy import QtGui -from qtpy.QtCore import QObject - -from bec_widgets.utils import BECConnector, ConnectionConfig - - -class ProgressbarConnections(BaseModel): - slot: Literal["on_scan_progress", "on_device_readback", None] = None - endpoint: EndpointInfo | str | None = None - model_config: dict = {"validate_assignment": True} - - @field_validator("endpoint") - @classmethod - def validate_endpoint(cls, v, values): - slot = values.data["slot"] - v = v.endpoint if isinstance(v, EndpointInfo) else v - if slot == "on_scan_progress": - if v != MessageEndpoints.scan_progress().endpoint: - raise PydanticCustomError( - "unsupported endpoint", - "For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.", - {"wrong_value": v}, - ) - elif slot == "on_device_readback": - if not v.startswith(MessageEndpoints.device_readback("").endpoint): - raise PydanticCustomError( - "unsupported endpoint", - "For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.", - {"wrong_value": v}, - ) - return v +from bec_lib.logger import bec_logger +from pydantic import Field +from qtpy import QtCore, QtGui +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig +from bec_widgets.utils.colors import Colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot + +logger = bec_logger.logger +if TYPE_CHECKING: + from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressContainerWidget, + ) class ProgressbarConfig(ConnectionConfig): - value: int | float | None = Field(0, description="Value for the progress bars.") - direction: int | None = Field( + value: int | float = Field(0, description="Value for the progress bars.") + direction: int = Field( -1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise." ) - color: str | tuple | None = Field( + color: str | tuple = Field( (0, 159, 227, 255), description="Color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.", ) - background_color: str | tuple | None = Field( + background_color: str | tuple = Field( (200, 200, 200, 50), description="Background color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.", ) - index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.") - line_width: int | None = Field(10, description="Line widths for the progress bars.") - start_position: int | None = Field( + link_colors: bool = Field( + True, + description="Whether to link the background color to the main color. If True, changing the main color will also change the background color.", + ) + line_width: int = Field(20, description="Line widths for the progress bars.") + start_position: int = Field( 90, description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to " "the top of the ring.", ) - min_value: int | float | None = Field(0, description="Minimum value for the progress bars.") - max_value: int | float | None = Field(100, description="Maximum value for the progress bars.") - precision: int | None = Field(3, description="Precision for the progress bars.") - update_behaviour: Literal["manual", "auto"] | None = Field( - "auto", description="Update behaviour for the progress bars." + min_value: int | float = Field(0, description="Minimum value for the progress bars.") + max_value: int | float = Field(100, description="Maximum value for the progress bars.") + precision: int = Field(3, description="Precision for the progress bars.") + mode: Literal["manual", "scan", "device"] = Field( + "manual", description="Update mode for the progress bars." ) - connections: ProgressbarConnections | None = Field( - default_factory=ProgressbarConnections, description="Connections for the progress bars." + device: str | None = Field( + None, + description="Device name for the device readback mode, only used when mode is 'device'.", ) - - -class RingConfig(ProgressbarConfig): - index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.") - start_position: int | None = Field( - 90, - description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to " - "the top of the ring.", + signal: str | None = Field( + None, + description="Signal name for the device readback mode, only used when mode is 'device'.", ) -class Ring(BECConnector, QObject): +class Ring(BECConnector, QWidget): USER_ACCESS = [ - "_get_all_rpc", - "_rpc_id", - "_config_dict", "set_value", "set_color", "set_background", + "set_colors_linked", "set_line_width", "set_min_max_values", "set_start_angle", "set_update", - "reset_connection", + "set_precision", ] - - def __init__( - self, - parent=None, - config: RingConfig | dict | None = None, - client=None, - gui_id: Optional[str] = None, - **kwargs, - ): - if config is None: - config = RingConfig(widget_class=self.__class__.__name__) - self.config = config - else: - if isinstance(config, dict): - config = RingConfig(**config) - self.config = config - super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) - - self.parent_progress_widget = parent - - self.color = None - self.background_color = None - self.start_position = None - self.config = config + RPC = True + + def __init__(self, parent: RingProgressContainerWidget | None = None, client=None, **kwargs): + self.progress_container = parent + self.config: ProgressbarConfig = ProgressbarConfig(widget_class=self.__class__.__name__) # type: ignore + super().__init__(parent=parent, client=client, config=self.config, **kwargs) + self._color: QColor = self.convert_color(self.config.color) + self._background_color: QColor = self.convert_color(self.config.background_color) + self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None self.RID = None - self._init_config_params() - - def _init_config_params(self): - self.color = self.convert_color(self.config.color) - self.background_color = self.convert_color(self.config.background_color) + self._gap = 5 self.set_start_angle(self.config.start_position) - if self.config.connections: - self.set_connections(self.config.connections.slot, self.config.connections.endpoint) def set_value(self, value: int | float): """ @@ -133,11 +91,7 @@ def set_value(self, value: int | float): Args: value(int | float): Value for the ring widget """ - self.config.value = round( - float(max(self.config.min_value, min(self.config.max_value, value))), - self.config.precision, - ) - self.parent_progress_widget.update() + self.value = value def set_color(self, color: str | tuple): """ @@ -146,20 +100,53 @@ def set_color(self, color: str | tuple): Args: color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ - self.config.color = color - self.color = self.convert_color(color) - self.parent_progress_widget.update() + self._color = self.convert_color(color) + self.config.color = self._color.name() + + # Automatically set background color + if self.config.link_colors: + self._auto_set_background_color() - def set_background(self, color: str | tuple): + self.update() + + def set_background(self, color: str | tuple | QColor): """ - Set the background color for the ring widget + Set the background color for the ring widget. The background color is only used when colors are not linked. Args: color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ - self.config.background_color = color - self.color = self.convert_color(color) - self.parent_progress_widget.update() + # Only allow manual background color changes when colors are not linked + if self.config.link_colors: + return + + self._background_color = self.convert_color(color) + self.config.background_color = self._background_color.name() + self.update() + + def _auto_set_background_color(self): + """ + Automatically set the background color based on the main color and the current theme. + """ + palette = self.palette() + bg = palette.color(QtGui.QPalette.ColorRole.Window) + bg_color = Colors.subtle_background_color(self._color, bg) + self.config.background_color = bg_color.name() + self._background_color = bg_color + self.update() + + def set_colors_linked(self, linked: bool): + """ + Set whether the colors are linked for the ring widget. + If colors are linked, changing the main color will also change the background color. + + Args: + linked(bool): Whether to link the colors for the ring widget + """ + self.config.link_colors = linked + if linked: + self._auto_set_background_color() + self.update() def set_line_width(self, width: int): """ @@ -169,7 +156,7 @@ def set_line_width(self, width: int): width(int): Line width for the ring widget """ self.config.line_width = width - self.parent_progress_widget.update() + self.update() def set_min_max_values(self, min_value: int | float, max_value: int | float): """ @@ -181,35 +168,21 @@ def set_min_max_values(self, min_value: int | float, max_value: int | float): """ self.config.min_value = min_value self.config.max_value = max_value - self.parent_progress_widget.update() + self.update() def set_start_angle(self, start_angle: int): """ - Set the start angle for the ring widget + Set the start angle for the ring widget. Args: start_angle(int): Start angle for the ring widget in degrees """ self.config.start_position = start_angle - self.start_position = start_angle * 16 - self.parent_progress_widget.update() + self.update() - @staticmethod - def convert_color(color): - """ - Convert the color to QColor - - Args: - color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A). - """ - converted_color = None - if isinstance(color, str): - converted_color = QtGui.QColor(color) - elif isinstance(color, tuple): - converted_color = QtGui.QColor(*color) - return converted_color - - def set_update(self, mode: Literal["manual", "scan", "device"], device: str = None): + def set_update( + self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = "" + ): """ Set the update mode for the ring widget. Modes: @@ -220,47 +193,167 @@ def set_update(self, mode: Literal["manual", "scan", "device"], device: str = No Args: mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device" device(str): Device name for the device readback mode, only used when mode is "device" + signal(str): Signal name for the device readback mode, only used when mode is "device" """ - if mode == "manual": - if self.config.connections.slot is not None: - self.bec_dispatcher.disconnect_slot( - getattr(self, self.config.connections.slot), self.config.connections.endpoint + + match mode: + case "manual": + if self.config.mode == "manual": + return + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "manual" + self.registered_slot = None + case "scan": + if self.config.mode == "scan": + return + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "scan" + self.bec_dispatcher.connect_slot( + self.on_scan_progress, MessageEndpoints.scan_progress() ) - self.config.connections.slot = None - self.config.connections.endpoint = None - elif mode == "scan": - self.set_connections("on_scan_progress", MessageEndpoints.scan_progress()) - elif mode == "device": - self.set_connections("on_device_readback", MessageEndpoints.device_readback(device)) + self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress()) + case "device": + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "device" + if device == "": + self.registered_slot = None + return + self.config.device = device + # self.config.signal = self._get_signal_from_device(device, signal) + signal = self._update_device_connection(device, signal) + self.config.signal = signal + + case _: + raise ValueError(f"Unsupported mode: {mode}") + + def set_precision(self, precision: int): + """ + Set the precision for the ring widget. - self.parent_progress_widget.enable_auto_updates(False) + Args: + precision(int): Precision for the ring widget + """ + self.config.precision = precision + self.update() - def set_connections(self, slot: str, endpoint: str | EndpointInfo): + def set_direction(self, direction: int): """ - Set the connections for the ring widget + Set the direction for the ring widget. Args: - slot(str): Slot for the ring widget update. Can be "on_scan_progress" or "on_device_readback". - endpoint(str | EndpointInfo): Endpoint for the ring widget update. Endpoint has to match the slot type. + direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise. """ - if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot: - return - if self.config.connections.slot is not None: - self.bec_dispatcher.disconnect_slot( - getattr(self, self.config.connections.slot), self.config.connections.endpoint - ) - self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint) - self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint) + self.config.direction = direction + self.update() + + def _get_signals_for_device(self, device: str) -> dict[str, list[str]]: + """ + Get the signals for the device. - def reset_connection(self): + Args: + device(str): Device name for the device + + Returns: + dict[str, list[str]]: Dictionary with the signals for the device """ - Reset the connections for the ring widget. Disconnect the current slot and endpoint. + dm = self.bec_dispatcher.client.device_manager + if not dm: + raise ValueError("Device manager is not available in the BEC client.") + dev_obj = dm.devices.get(device) + if dev_obj is None: + raise ValueError(f"Device '{device}' not found in device manager.") + + progress_signals = [ + obj["component_name"] + for obj in dev_obj._info["signals"].values() + if obj["signal_class"] == "ProgressSignal" + ] + hinted_signals = [ + obj["obj_name"] + for obj in dev_obj._info["signals"].values() + if obj["kind_str"] == "hinted" + and obj["signal_class"] + not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"] + ] + + normal_signals = [ + obj["component_name"] + for obj in dev_obj._info["signals"].values() + if obj["kind_str"] == "normal" + ] + return { + "progress_signals": progress_signals, + "hinted_signals": hinted_signals, + "normal_signals": normal_signals, + } + + def _update_device_connection(self, device: str, signal: str | None) -> str: """ - self.bec_dispatcher.disconnect_slot( - self.config.connections.slot, self.config.connections.endpoint - ) - self.config.connections = ProgressbarConnections() + Update the device connection for the ring widget. + + In general, we support two modes here: + - If signal is provided, we use that directly. + - If signal is not provided, we try to get the signal from the device manager. + We first check for progress signals, then for hinted signals, and finally for normal signals. + + Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints. + + Args: + device(str): Device name for the device mode + signal(str): Signal name for the device mode + Returns: + str: The selected signal name for the device mode + """ + logger.info(f"Updating device connection for device '{device}' and signal '{signal}'") + dm = self.bec_dispatcher.client.device_manager + if not dm: + raise ValueError("Device manager is not available in the BEC client.") + dev_obj = dm.devices.get(device) + if dev_obj is None: + return "" + + signals = self._get_signals_for_device(device) + progress_signals = signals["progress_signals"] + hinted_signals = signals["hinted_signals"] + normal_signals = signals["normal_signals"] + + if not signal: + # If signal is not provided, we try to get it from the device manager + if len(progress_signals) > 0: + signal = progress_signals[0] + logger.info( + f"Using progress signal '{signal}' for device '{device}' in ring progress bar." + ) + elif len(hinted_signals) > 0: + signal = hinted_signals[0] + logger.info( + f"Using hinted signal '{signal}' for device '{device}' in ring progress bar." + ) + elif len(normal_signals) > 0: + signal = normal_signals[0] + logger.info( + f"Using normal signal '{signal}' for device '{device}' in ring progress bar." + ) + else: + logger.warning(f"No signals found for device '{device}' in ring progress bar.") + return "" + + if signal in progress_signals: + endpoint = MessageEndpoints.device_progress(device) + self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint) + self.registered_slot = (self.on_device_progress, endpoint) + return signal + if signal in hinted_signals or signal in normal_signals: + endpoint = MessageEndpoints.device_readback(device) + self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint) + self.registered_slot = (self.on_device_readback, endpoint) + return signal + + @SafeSlot(dict, dict) def on_scan_progress(self, msg, meta): """ Update the ring widget with the scan progress. @@ -273,8 +366,9 @@ def on_scan_progress(self, msg, meta): if current_RID != self.RID: self.set_min_max_values(0, msg.get("max_value", 100)) self.set_value(msg.get("value", 0)) - self.parent_progress_widget.update() + self.update() + @SafeSlot(dict, dict) def on_device_readback(self, msg, meta): """ Update the ring widget with the device readback. @@ -283,11 +377,242 @@ def on_device_readback(self, msg, meta): msg(dict): Message with the device readback meta(dict): Metadata for the message """ - if isinstance(self.config.connections.endpoint, EndpointInfo): - endpoint = self.config.connections.endpoint.endpoint - else: - endpoint = self.config.connections.endpoint - device = endpoint.split("/")[-1] - value = msg.get("signals").get(device).get("value") + device = self.config.device + if device is None: + return + signal = self.config.signal or device + value = msg.get("signals", {}).get(signal, {}).get("value", None) + if value is None: + return + self.set_value(value) + self.update() + + @SafeSlot(dict, dict) + def on_device_progress(self, msg, meta): + """ + Update the ring widget with the device progress. + + Args: + msg(dict): Message with the device progress + meta(dict): Metadata for the message + """ + device = self.config.device + if device is None: + return + max_val = msg.get("max_value", 100) + self.set_min_max_values(0, max_val) + value = msg.get("value", 0) + if msg.get("done"): + value = max_val self.set_value(value) - self.parent_progress_widget.update() + self.update() + + def paintEvent(self, event): + if not self.progress_container: + return + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + size = min(self.width(), self.height()) + + # Center the ring + x_offset = (self.width() - size) // 2 + y_offset = (self.height() - size) // 2 + + max_ring_size = self.progress_container.get_max_ring_size() + + rect = QtCore.QRect(x_offset, y_offset, size, size) + rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size) + + # Background arc + painter.setPen( + QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine) + ) + + gap: int = self.gap # type: ignore + + # Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16. + start_position: float = self.config.start_position * 16 # type: ignore + + adjusted_rect = QtCore.QRect( + rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap + ) + painter.drawArc(adjusted_rect, start_position, 360 * 16) + + # Foreground arc + pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + proportion = (self.config.value - self.config.min_value) / ( + (self.config.max_value - self.config.min_value) + 1e-3 + ) + angle = int(proportion * 360 * 16 * self.config.direction) + painter.drawArc(adjusted_rect, start_position, angle) + painter.end() + + def convert_color(self, color: str | tuple | QColor) -> QColor: + """ + Convert the color to QColor + + Args: + color(str | tuple | QColor): Color for the ring widget. Can be HEX code or tuple (R, G, B, A) or QColor. + """ + + if isinstance(color, QColor): + return color + if isinstance(color, str): + return QtGui.QColor(color) + if isinstance(color, (tuple, list)): + return QtGui.QColor(*color) + raise ValueError(f"Unsupported color format: {color}") + + def cleanup(self): + """ + Cleanup the ring widget. + Disconnect any registered slots. + """ + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.registered_slot = None + + ############################################### + ####### QProperties ########################### + ############################################### + + @SafeProperty(int) + def gap(self) -> int: + return self._gap + + @gap.setter + def gap(self, value: int): + self._gap = value + self.update() + + @SafeProperty(bool) + def link_colors(self) -> bool: + return self.config.link_colors + + @link_colors.setter + def link_colors(self, value: bool): + logger.info(f"Setting link_colors to {value}") + self.set_colors_linked(value) + + @SafeProperty(QColor) + def color(self) -> QColor: + return self._color + + @color.setter + def color(self, value: QColor): + self.set_color(value) + + @SafeProperty(QColor) + def background_color(self) -> QColor: + return self._background_color + + @background_color.setter + def background_color(self, value: QColor): + self.set_background(value) + + @SafeProperty(float) + def value(self) -> float: + return self.config.value + + @value.setter + def value(self, value: float): + self.config.value = round( + float(max(self.config.min_value, min(self.config.max_value, value))), + self.config.precision, + ) + self.update() + + @SafeProperty(float) + def min_value(self) -> float: + return self.config.min_value + + @min_value.setter + def min_value(self, value: float): + self.config.min_value = value + self.update() + + @SafeProperty(float) + def max_value(self) -> float: + return self.config.max_value + + @max_value.setter + def max_value(self, value: float): + self.config.max_value = value + self.update() + + @SafeProperty(str) + def mode(self) -> str: + return self.config.mode + + @mode.setter + def mode(self, value: str): + self.set_update(value) + + @SafeProperty(str) + def device(self) -> str: + return self.config.device or "" + + @device.setter + def device(self, value: str): + self.config.device = value + + @SafeProperty(str) + def signal(self) -> str: + return self.config.signal or "" + + @signal.setter + def signal(self, value: str): + self.config.signal = value + + @SafeProperty(int) + def line_width(self) -> int: + return self.config.line_width + + @line_width.setter + def line_width(self, value: int): + self.config.line_width = value + self.update() + + @SafeProperty(int) + def start_position(self) -> int: + return self.config.start_position + + @start_position.setter + def start_position(self, value: int): + self.config.start_position = value + self.update() + + @SafeProperty(int) + def precision(self) -> int: + return self.config.precision + + @precision.setter + def precision(self, value: int): + self.config.precision = value + self.update() + + @SafeProperty(int) + def direction(self) -> int: + return self.config.direction + + @direction.setter + def direction(self, value: int): + self.config.direction = value + self.update() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + ring = Ring() + ring.export_settings() + ring.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index eeb413070..0a6c8dd8b 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -1,345 +1,154 @@ -from __future__ import annotations - -from typing import Literal, Optional +import json +from typing import Literal import pyqtgraph as pg -from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger -from pydantic import Field, field_validator -from pydantic_core import PydanticCustomError -from qtpy import QtCore, QtGui -from qtpy.QtCore import QSize, Slot -from qtpy.QtWidgets import QSizePolicy, QWidget +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget -from bec_widgets.utils import Colors, ConnectionConfig, EntryValidator +from bec_widgets.utils import Colors from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring, RingConfig +from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.settings_dialog import SettingsDialog +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings logger = bec_logger.logger -class RingProgressBarConfig(ConnectionConfig): - color_map: Optional[str] = Field( - "plasma", description="Color scheme for the progress bars.", validate_default=True - ) - min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.") - max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.") - num_bars: int = Field(1, description="Number of progress bars to display.") - gap: int | None = Field(20, description="Gap between progress bars.") - auto_updates: bool | None = Field( - True, description="Enable or disable updates based on scan queue status." - ) - rings: list[RingConfig] | None = Field([], description="List of ring configurations.") - - @field_validator("num_bars") - @classmethod - def validate_num_bars(cls, v, values): - min_number_of_bars = values.data.get("min_number_of_bars", None) - max_number_of_bars = values.data.get("max_number_of_bars", None) - if min_number_of_bars is not None and max_number_of_bars is not None: - logger.info( - f"Number of bars adjusted to be between defined min:{min_number_of_bars} and max:{max_number_of_bars} number of bars." - ) - v = max(min_number_of_bars, min(v, max_number_of_bars)) - return v - - @field_validator("rings") - @classmethod - def validate_rings(cls, v, values): - if v is not None and v is not []: - num_bars = values.data.get("num_bars", None) - if len(v) != num_bars: - raise PydanticCustomError( - "different number of configs", - f"Length of rings configuration ({len(v)}) does not match the number of bars ({num_bars}).", - {"wrong_value": len(v)}, - ) - indices = [ring.index for ring in v] - if sorted(indices) != list(range(len(indices))): - raise PydanticCustomError( - "wrong indices", - f"Indices of ring configurations must be unique and in order from 0 to num_bars {num_bars}.", - {"wrong_value": indices}, - ) - return v - - _validate_colormap = field_validator("color_map")(Colors.validate_color_map) - - -class RingProgressBar(BECWidget, QWidget): +class RingProgressContainerWidget(QWidget): """ - Show the progress of devices, scans or custom values in the form of ring progress bars. + A container widget for the Ring Progress Bar widget. + It holds the rings and manages their layout and painting. """ - PLUGIN = True - ICON_NAME = "track_changes" - USER_ACCESS = [ - "_get_all_rpc", - "_rpc_id", - "_config_dict", - "rings", - "update_config", - "add_ring", - "remove_ring", - "set_precision", - "set_min_max_values", - "set_number_of_bars", - "set_value", - "set_colors_from_map", - "set_colors_directly", - "set_line_widths", - "set_gap", - "set_diameter", - "reset_diameter", - "enable_auto_updates", - ] - - def __init__( - self, - parent=None, - config: RingProgressBarConfig | dict | None = None, - client=None, - gui_id: str | None = None, - num_bars: int | None = None, - **kwargs, - ): - if config is None: - config = RingProgressBarConfig(widget_class=self.__class__.__name__) - self.config = config - else: - if isinstance(config, dict): - config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__) - self.config = config - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - - self.get_bec_shortcuts() - self.entry_validator = EntryValidator(self.dev) - - self.RID = None - - # For updating bar behaviour - self._auto_updates = True - self._rings = [] - - if num_bars is not None: - self.config.num_bars = max( - self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars) - ) + def __init__(self, parent: QWidget | None = None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.rings: list[Ring] = [] + self.gap = 20 # Gap between rings + self.color_map: str = "turbo" + self.setLayout(QHBoxLayout()) self.initialize_bars() - - self.enable_auto_updates(self.config.auto_updates) + self.initialize_center_label() @property - def rings(self) -> list[Ring]: - """Returns a list of all rings in the progress bar.""" - return self._rings - - @rings.setter - def rings(self, value: list[Ring]): - self._rings = value - - def update_config(self, config: RingProgressBarConfig | dict): - """ - Update the configuration of the widget. - - Args: - config(SpiralProgressBarConfig|dict): Configuration to update. - """ - if isinstance(config, dict): - config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__) - self.config = config - self.clear_all() + def num_bars(self) -> int: + return len(self.rings) def initialize_bars(self): """ Initialize the progress bars. """ - start_positions = [90 * 16] * self.config.num_bars - directions = [-1] * self.config.num_bars + for _ in range(self.num_bars): + self.add_ring() - self.config.rings = [ - RingConfig( - widget_class="Ring", - index=i, - start_positions=start_positions[i], - directions=directions[i], - ) - for i in range(self.config.num_bars) - ] - self._rings = [Ring(parent=self, config=config) for config in self.config.rings] - - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - - min_size = self._calculate_minimum_size() - self.setMinimumSize(min_size) - # Set outer ring to listen to scan progress - self.rings[0].set_update(mode="scan") - self.update() + if self.color_map: + self.set_colors_from_map(self.color_map) - def add_ring(self, **kwargs) -> Ring: + def add_ring(self, config: dict | None = None) -> Ring: """ - Add a new progress bar. + Add a new ring to the container. Args: - **kwargs: Keyword arguments for the new progress bar. + config(dict | None): Optional configuration dictionary for the ring. Returns: - Ring: Ring object. - """ - if self.config.num_bars < self.config.max_number_of_bars: - ring_index = self.config.num_bars - ring_config = RingConfig( - widget_class="Ring", - index=ring_index, - start_positions=90 * 16, - directions=-1, - **kwargs, - ) - ring = Ring(parent=self, config=ring_config) - self.config.num_bars += 1 - self._rings.append(ring) - self.config.rings.append(ring.config) - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - base_line_width = self._rings[ring.config.index].config.line_width - self.set_line_widths(base_line_width, ring.config.index) - self.update() - return ring + Ring: The newly added ring object. + """ + ring = Ring(parent=self) + ring.setGeometry(self.rect()) + ring.gap = self.gap * len(self.rings) + ring.set_value(0) + self.rings.append(ring) + if config: + # We have to first get the link_colors property before loading the settings + # While this is an ugly hack, we do not have control over the order of properties + # being set when loading. + ring.link_colors = config.pop("link_colors", True) + ring.load_settings(config) + if self.color_map: + self.set_colors_from_map(self.color_map) + ring.show() + ring.raise_() + self.update() + return ring - def remove_ring(self, index: int): + def remove_ring(self, index: int | None = None): """ - Remove a progress bar by index. + Remove a ring from the container. Args: - index(int): Index of the progress bar to remove. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ - ring = self._find_ring_by_index(index) - self._cleanup_ring(ring) - self.update() - - def _cleanup_ring(self, ring: Ring) -> None: - ring.reset_connection() - self._rings.remove(ring) - self.config.rings.remove(ring.config) - self.config.num_bars -= 1 - self._reindex_rings() - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - # Remove ring from rpc, afterwards call close event. - ring.rpc_register.remove_rpc(ring) + if self.num_bars == 0: + return + if index is None: + index = self.num_bars - 1 + index = self._validate_index(index) + ring = self.rings[index] + ring.cleanup() + ring.close() ring.deleteLater() - # del ring + self.rings.pop(index) + # Update gaps for remaining rings + for i, r in enumerate(self.rings): + r.gap = self.gap * i + self.update() - def _reindex_rings(self): + def initialize_center_label(self): """ - Reindex the progress bars. + Initialize the center label. """ - for i, ring in enumerate(self._rings): - ring.config.index = i + layout = self.layout() + layout.setContentsMargins(0, 0, 0, 0) - def set_precision(self, precision: int, bar_index: int | None = None): - """ - Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars. + self.center_label = QLabel("", parent=self) + self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.center_label) - Args: - precision(int): Precision for the progress bars. - bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set. + def _calculate_minimum_size(self): """ - if bar_index is not None: - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - ring.config.precision = precision - else: - for ring in self._rings: - ring.config.precision = precision - self.update() - - def set_min_max_values( - self, - min_values: int | float | list[int | float], - max_values: int | float | list[int | float], - ): + Calculate the minimum size of the widget. """ - Set the minimum and maximum values for the progress bars. + if not self.rings: + return QSize(10, 10) + ring_widths = self.get_ring_line_widths() + total_width = sum(ring_widths) + self.gap * (self.num_bars - 1) + diameter = max(total_width * 2, 50) - Args: - min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar. - max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar. - """ - if isinstance(min_values, (int, float)): - min_values = [min_values] - if isinstance(max_values, (int, float)): - max_values = [max_values] - min_values = self._adjust_list_to_bars(min_values) - max_values = self._adjust_list_to_bars(max_values) - for ring, min_value, max_value in zip(self._rings, min_values, max_values): - ring.set_min_max_values(min_value, max_value) - self.update() + return QSize(diameter, diameter) - def set_number_of_bars(self, num_bars: int): + def get_ring_line_widths(self): """ - Set the number of progress bars to display. - - Args: - num_bars(int): Number of progress bars to display. + Get the line widths of the rings. """ - num_bars = max( - self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars) - ) - current_num_bars = self.config.num_bars - - if num_bars > current_num_bars: - for i in range(current_num_bars, num_bars): - new_ring_config = RingConfig( - widget_class="Ring", index=i, start_positions=90 * 16, directions=-1 - ) - self.config.rings.append(new_ring_config) - new_ring = Ring(parent=self, config=new_ring_config) - self._rings.append(new_ring) - - elif num_bars < current_num_bars: - for i in range(current_num_bars - 1, num_bars - 1, -1): - self.remove_ring(i) + if not self.rings: + return [10] + ring_widths = [ring.config.line_width for ring in self.rings] + return ring_widths - self.config.num_bars = num_bars - - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - - base_line_width = self._rings[0].config.line_width - self.set_line_widths(base_line_width) + def get_max_ring_size(self) -> int: + """ + Get the size of the rings. + """ + if not self.rings: + return 10 + ring_widths = self.get_ring_line_widths() + return max(ring_widths) - self.update() + def sizeHint(self): + min_size = self._calculate_minimum_size() + return min_size - def set_value(self, values: int | list, ring_index: int = None): + def resizeEvent(self, event): """ - Set the values for the progress bars. - - Args: - values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar. - ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set. - - Examples: - >>> SpiralProgressBar.set_value(50) - >>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner) - >>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar. - """ - if ring_index is not None: - ring = self._find_ring_by_index(ring_index) - if isinstance(values, list): - values = values[0] - logger.warning( - f"Warning: Only a single value can be set for a single progress bar. Using the first value in the list {values}" - ) - ring.set_value(values) - else: - if isinstance(values, int): - values = [values] - values = self._adjust_list_to_bars(values) - for ring, value in zip(self._rings, values): - ring.set_value(value) - self.update() + Handle resize events to update ring geometries. + """ + super().resizeEvent(event) + for ring in self.rings: + ring.setGeometry(self.rect()) def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"): """ @@ -353,12 +162,14 @@ def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "R raise ValueError( f"Colormap '{colormap}' not found in the current installation of pyqtgraph" ) - colors = Colors.golden_angle_color(colormap, self.config.num_bars, color_format) + colors = Colors.golden_angle_color(colormap, self.num_bars, color_format) self.set_colors_directly(colors) - self.config.color_map = colormap + self.color_map = colormap self.update() - def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index: int = None): + def set_colors_directly( + self, colors: list[str | tuple] | str | tuple, bar_index: int | None = None + ): """ Set the colors for the progress bars directly. @@ -367,281 +178,275 @@ def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set. """ if bar_index is not None and isinstance(colors, (str, tuple)): - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - ring.set_color(colors) + bar_index = self._validate_index(bar_index) + self.rings[bar_index].set_color(colors) else: if isinstance(colors, (str, tuple)): colors = [colors] colors = self._adjust_list_to_bars(colors) - for ring, color in zip(self._rings, colors): + for ring, color in zip(self.rings, colors): ring.set_color(color) self.update() - def set_line_widths(self, widths: int | list[int], bar_index: int = None): + def _adjust_list_to_bars(self, items: list) -> list: """ - Set the line widths for the progress bars. + Utility method to adjust the list of parameters to match the number of progress bars. Args: - widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar. - bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set. - """ - if bar_index is not None: - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - if isinstance(widths, list): - widths = widths[0] - logger.warning( - f"Warning: Only a single line width can be set for a single progress bar. Using the first value in the list {widths}" - ) - ring.set_line_width(widths) - else: - if isinstance(widths, int): - widths = [widths] - widths = self._adjust_list_to_bars(widths) - self.config.gap = max(widths) * 2 - for ring, width in zip(self._rings, widths): - ring.set_line_width(width) - min_size = self._calculate_minimum_size() - self.setMinimumSize(min_size) - self.update() - - def set_gap(self, gap: int): - """ - Set the gap between the progress bars. + items(list): List of parameters for the progress bars. - Args: - gap(int): Gap between the progress bars. + Returns: + list: List of parameters for the progress bars. """ - self.config.gap = gap - self.update() + if items is None: + raise ValueError( + "Items cannot be None. Please provide a list for parameters for the progress bars." + ) + if not isinstance(items, list): + items = [items] + if len(items) < self.num_bars: + last_item = items[-1] + items.extend([last_item] * (self.num_bars - len(items))) + elif len(items) > self.num_bars: + items = items[: self.num_bars] + return items - def set_diameter(self, diameter: int): + def _validate_index(self, index: int) -> int: """ - Set the diameter of the widget. + Check if the provided index is valid for the number of bars. Args: - diameter(int): Diameter of the widget. + index(int): Index to check. + Returns: + int: Validated index. """ - size = QSize(diameter, diameter) - self.resize(size) - self.setFixedSize(size) + try: + self.rings[index] + except IndexError: + raise IndexError(f"Index {index} is out of range for {self.num_bars} rings.") + return index - def _find_ring_by_index(self, index: int) -> Ring: + def clear_all(self): + """ + Clear all rings from the widget. """ - Find the ring by index. + for ring in self.rings: + ring.close() + ring.deleteLater() + self.rings = [] + self.update() - Args: - index(int): Index of the ring. - Returns: - Ring: Ring object. - """ - for ring in self._rings: - if ring.config.index == index: - return ring - raise ValueError(f"Ring with index {index} not found.") +class RingProgressBar(BECWidget, QWidget): + ICON_NAME = "track_changes" + PLUGIN = True + RPC = True - def enable_auto_updates(self, enable: bool = True): - """ - Enable or disable updates based on scan status. Overrides manual updates. - The behaviour of the whole progress bar widget will be driven by the scan queue status. + USER_ACCESS = [ + *BECWidget.USER_ACCESS, + "screenshot", + "rings", + "add_ring", + "remove_ring", + "set_gap", + "set_center_label", + ] - Args: - enable(bool): True or False. + def __init__(self, parent: QWidget | None = None, client=None, **kwargs): + super().__init__(parent=parent, client=client, theme_update=True, **kwargs) - Returns: - bool: True if scan segment updates are enabled. - """ + self.setWindowTitle("Ring Progress Bar") - self._auto_updates = enable - if enable is True: - self.bec_dispatcher.connect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - else: - self.bec_dispatcher.disconnect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - return self._auto_updates + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) - @Slot(dict, dict) - def on_scan_queue_status(self, msg, meta): - """ - Slot to handle scan queue status messages. Decides what update to perform based on the scan queue status. + self.setLayout(self.layout) - Args: - msg(dict): Message from the BEC. - meta(dict): Metadata from the BEC. - """ - primary_queue = msg.get("queue").get("primary") - info = primary_queue.get("info", None) + self.toolbar = ModularToolBar(self) + self._init_toolbar() + self.layout.addWidget(self.toolbar) - if not info: - return - active_request_block = info[0].get("active_request_block", None) - if not active_request_block: - return - report_instructions = active_request_block.get("report_instructions", None) - if not report_instructions: - return + # Placeholder for the actual ring progress bar widget + self.ring_progress_bar = RingProgressContainerWidget(self) + self.layout.addWidget(self.ring_progress_bar) + + self.settings_dialog = None + + self.toolbar.show_bundles(["rpb_settings"]) + + def apply_theme(self, theme: str): + super().apply_theme(theme) + if self.ring_progress_bar.color_map: + self.ring_progress_bar.set_colors_from_map(self.ring_progress_bar.color_map) + + def _init_toolbar(self): + settings_action = MaterialIconAction( + icon_name="settings", + tooltip="Show Ring Progress Bar Settings", + checkable=True, + parent=self, + ) + self.toolbar.add_action("rpb_settings", settings_action) + settings_action.action.triggered.connect(self._open_settings_dialog) + + def _open_settings_dialog(self): + """ " + Open the settings dialog for the ring progress bar. + """ + settings_action = self.toolbar.components.get_action("rpb_settings").action + if self.settings_dialog is None or not self.settings_dialog.isVisible(): + settings = RingSettings(parent=self, target_widget=self, popup=True) + self.settings_dialog = SettingsDialog( + self, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + self.settings_dialog.resize(900, 500) + self.settings_dialog.finished.connect(self._settings_dialog_closed) + self.settings_dialog.show() - instruction_type = list(report_instructions[0].keys())[0] - if instruction_type == "scan_progress": - self._hook_scan_progress(ring_index=0) - elif instruction_type == "readback": - devices = report_instructions[0].get("readback").get("devices") - start = report_instructions[0].get("readback").get("start") - end = report_instructions[0].get("readback").get("end") - if self.config.num_bars != len(devices): - self.set_number_of_bars(len(devices)) - for index, device in enumerate(devices): - self._hook_readback(index, device, start[index], end[index]) + settings_action.setChecked(True) else: - logger.error(f"{instruction_type} not supported yet.") + # Dialog is already open, raise it + self.settings_dialog.raise_() + self.settings_dialog.activateWindow() + settings_action.setChecked(True) - def _hook_scan_progress(self, ring_index: int | None = None): + def _settings_dialog_closed(self): """ - Hook the scan progress to the progress bars. + Handle the settings dialog being closed. + """ + settings_action = self.toolbar.components.get_action("rpb_settings").action + settings_action.setChecked(False) + self.settings_dialog = None - Args: - ring_index(int): Index of the progress bar to hook the scan progress to. + ################################################# + ###### RPC User Access Methods ################## + ################################################# + + def add_ring(self, config: dict | None = None) -> Ring: """ - if ring_index is not None: - ring = self._find_ring_by_index(ring_index) - else: - ring = self._rings[0] + Add a new ring to the ring progress bar. + Optionally, a configuration dictionary can be provided but the ring + can also be configured later. The config dictionary must provide + the qproperties of the Qt Ring object. - if ring.config.connections.slot == "on_scan_progress": - return - ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress()) + Args: + config(dict | None): Optional configuration dictionary for the ring. - def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int): + Returns: + Ring: The newly added ring object. """ - Hook the readback values to the progress bars. + return self.ring_progress_bar.add_ring(config=config) + def remove_ring(self, index: int | None = None): + """ + Remove a ring from the ring progress bar. Args: - bar_index(int): Index of the progress bar to hook the readback values to. - device(str): Device to readback values from. - min(float|int): Minimum value for the progress bar. - max(float|int): Maximum value for the progress bar. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ - ring = self._find_ring_by_index(bar_index) - ring.set_min_max_values(min, max) - endpoint = MessageEndpoints.device_readback(device) - ring.set_connections("on_device_readback", endpoint) + if self.ring_progress_bar.num_bars == 0: + return + self.ring_progress_bar.remove_ring(index=index) - def _adjust_list_to_bars(self, items: list) -> list: + def set_gap(self, value: int): """ - Utility method to adjust the list of parameters to match the number of progress bars. + Set the gap between rings. Args: - items(list): List of parameters for the progress bars. - - Returns: - list: List of parameters for the progress bars. + value(int): Gap value in pixels. """ - if items is None: - raise ValueError( - "Items cannot be None. Please provide a list for parameters for the progress bars." - ) - if not isinstance(items, list): - items = [items] - if len(items) < self.config.num_bars: - last_item = items[-1] - items.extend([last_item] * (self.config.num_bars - len(items))) - elif len(items) > self.config.num_bars: - items = items[: self.config.num_bars] - return items + self.gap = value - def _bar_index_check(self, bar_index: int): + def set_center_label(self, text: str): """ - Utility method to check if the bar index is within the range of the number of progress bars. + Set the center label text. Args: - bar_index(int): Index of the progress bar to set the value for. + text(str): Text for the center label. """ - if not (0 <= bar_index < self.config.num_bars): - raise ValueError( - f"bar_index {bar_index} out of range of number of bars {self.config.num_bars}." - ) - return bar_index + self.center_label = text + + @property + def rings(self) -> list[Ring]: + return self.ring_progress_bar.rings + + ############################################### + ####### QProperties ########################### + ############################################### - def paintEvent(self, event): - if not self._rings: + @SafeProperty(int) + def gap(self) -> int: + return self.ring_progress_bar.gap + + @gap.setter + def gap(self, value: int): + self.ring_progress_bar.gap = value + self.ring_progress_bar.update() + + @SafeProperty(str) + def color_map(self) -> str: + return self.ring_progress_bar.color_map or "" + + @color_map.setter + def color_map(self, colormap: str): + if colormap == "": + self.ring_progress_bar.color_map = "" return - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - size = min(self.width(), self.height()) - rect = QtCore.QRect(0, 0, size, size) - rect.adjust( - max(ring.config.line_width for ring in self._rings), - max(ring.config.line_width for ring in self._rings), - -max(ring.config.line_width for ring in self._rings), - -max(ring.config.line_width for ring in self._rings), - ) + if colormap not in pg.colormap.listMaps(): + return + self.ring_progress_bar.set_colors_from_map(colormap) + self.ring_progress_bar.color_map = colormap - for i, ring in enumerate(self._rings): - # Background arc - painter.setPen( - QtGui.QPen(ring.background_color, ring.config.line_width, QtCore.Qt.SolidLine) - ) - offset = self.config.gap * i - adjusted_rect = QtCore.QRect( - rect.left() + offset, - rect.top() + offset, - rect.width() - 2 * offset, - rect.height() - 2 * offset, - ) - painter.drawArc(adjusted_rect, ring.config.start_position, 360 * 16) - - # Foreground arc - pen = QtGui.QPen(ring.color, ring.config.line_width, QtCore.Qt.SolidLine) - pen.setCapStyle(QtCore.Qt.RoundCap) - painter.setPen(pen) - proportion = (ring.config.value - ring.config.min_value) / ( - (ring.config.max_value - ring.config.min_value) + 1e-3 - ) - angle = int(proportion * 360 * 16 * ring.config.direction) - painter.drawArc(adjusted_rect, ring.start_position, angle) + @SafeProperty(str) + def center_label(self) -> str: + return self.ring_progress_bar.center_label.text() + + @center_label.setter + def center_label(self, text: str): + self.ring_progress_bar.center_label.setText(text) - def reset_diameter(self): + @SafeProperty(str, designable=False, popup_error=True) + def ring_json(self) -> str: """ - Reset the fixed size of the widget. + A JSON string property that serializes all ring pydantic configs. """ - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - self.setMinimumSize(self._calculate_minimum_size()) - self.setMaximumSize(16777215, 16777215) + raw_list = [] + for ring in self.rings: + cfg_dict = ring.config.model_dump() + raw_list.append(cfg_dict) + return json.dumps(raw_list, indent=2) - def _calculate_minimum_size(self): + @ring_json.setter + def ring_json(self, json_data: str): """ - Calculate the minimum size of the widget. + Load rings from a JSON string and add them to the ring progress bar. """ - if not self.config.rings: - logger.warning("no rings to get size from setting size to 10x10") - return QSize(10, 10) - ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)] - total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1) - diameter = max(total_width * 2, 50) + try: + ring_configs = json.loads(json_data) + self.ring_progress_bar.clear_all() + for cfg_dict in ring_configs: + self.add_ring(config=cfg_dict) + except json.JSONDecodeError as e: + logger.error(f"Failed to decode JSON: {e}") - return QSize(diameter, diameter) + def cleanup(self): + self.ring_progress_bar.clear_all() + self.ring_progress_bar.close() + self.ring_progress_bar.deleteLater() + super().cleanup() - def sizeHint(self): - min_size = self._calculate_minimum_size() - return min_size - def clear_all(self): - for ring in self._rings: - ring.reset_connection() - self._rings.clear() - self.update() - self.initialize_bars() +if __name__ == "__main__": # pragma: no cover + import sys - def cleanup(self): - self.bec_dispatcher.disconnect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - for ring in self._rings: - self._cleanup_ring(ring) - self._rings.clear() - super().cleanup() + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = RingProgressBar() + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py index f329166b4..bdc8a5594 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py @@ -51,7 +51,7 @@ def name(self): return "RingProgressBar" def toolTip(self): - return "" + return "RingProgressBar" def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py new file mode 100644 index 000000000..405646b09 --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py @@ -0,0 +1,509 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from bec_qthemes._icon.material_icons import material_icon +from qtpy.QtCore import QSize +from qtpy.QtGui import QColor +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils import UILoader +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.settings_dialog import SettingWidget +from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring +from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressBar, + RingProgressContainerWidget, + ) + + +class RingCardWidget(QFrame): + def __init__(self, ring: Ring, container: RingProgressContainerWidget, parent=None): + super().__init__(parent) + + self.ring = ring + self.container = container + self.details_visible = False + self.setProperty("skip_settings", True) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setObjectName("RingCardWidget") + + bg = self._get_theme_color("BORDER") + self.setStyleSheet( + f""" + #RingCardWidget {{ + border: 1px solid {bg.name() if bg else '#CCCCCC'}; + border-radius: 4px; + }} + """ + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + self._init_header(layout) + self._init_details(layout) + + self._init_values() + self._connect_signals() + self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode)) + self._set_widget_mode_enabled(self.ring.config.mode) + + def _get_theme_color(self, color_name: str) -> QColor | None: + app = QApplication.instance() + if not app: + return + if not app.theme: + return + return app.theme.color(color_name) + + def _init_header(self, parent_layout: QVBoxLayout): + """Create the collapsible header with basic controls""" + header = QWidget() + layout = QHBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + self.expand_btn = QPushButton("▶") + self.expand_btn.setFixedWidth(24) + self.expand_btn.clicked.connect(self.toggle_details) + + self.mode_combo = QComboBox() + self.mode_combo.addItems(["Manual", "Scan Progress", "Device Readback"]) + self.mode_combo.currentTextChanged.connect(self._update_mode) + + delete_btn = QPushButton(material_icon("delete"), "") + + color = self._get_theme_color("ACCENT_HIGHLIGHT") + delete_btn.setStyleSheet(f"background-color: {color.name() if color else '#CC181E'}") + delete_btn.clicked.connect(self._delete_self) + + layout.addWidget(self.expand_btn) + layout.addWidget(QLabel("Mode")) + layout.addWidget(self.mode_combo) + layout.addStretch() + layout.addWidget(delete_btn) + + parent_layout.addWidget(header) + + def _init_details(self, parent_layout: QVBoxLayout): + """Create the collapsible details area with the UI file""" + self.details = QWidget() + self.details.setVisible(False) + + details_layout = QVBoxLayout(self.details) + details_layout.setContentsMargins(0, 0, 0, 0) + + # Load UI file into details area + current_path = os.path.dirname(__file__) + self.ui = UILoader().load_ui(os.path.join(current_path, "ring_settings.ui"), self.details) + details_layout.addWidget(self.ui) + + parent_layout.addWidget(self.details) + + def toggle_details(self): + """Toggle visibility of the details area""" + self.details_visible = not self.details_visible + self.details.setVisible(self.details_visible) + self.expand_btn.setText("▼" if self.details_visible else "▶") + + # -------------------------------------------------------- + + def _connect_signals(self): + """Connect UI signals to ring methods""" + # Data connections + self.ui.value_spin_box.valueChanged.connect(self.ring.set_value) + self.ui.min_spin_box.valueChanged.connect(self._update_min_max) + self.ui.max_spin_box.valueChanged.connect(self._update_min_max) + + # Config connections + self.ui.start_angle_spin_box.valueChanged.connect(self.ring.set_start_angle) + self.ui.direction_combo_box.currentIndexChanged.connect(self._update_direction) + self.ui.line_width_spin_box.valueChanged.connect(self.ring.set_line_width) + self.ui.background_color_button.color_changed.connect(self.ring.set_background) + self.ui.ring_color_button.color_changed.connect(self._on_ring_color_changed) + self.ui.device_combo_box.device_selected.connect(self._on_device_changed) + self.ui.signal_combo_box.device_signal_changed.connect(self._on_signal_changed) + + def _init_values(self): + """Initialize UI values from ring config""" + # Data values + self.ui.value_spin_box.setRange(-1e6, 1e6) + self.ui.value_spin_box.setValue(self.ring.config.value) + + self.ui.min_spin_box.setRange(-1e6, 1e6) + self.ui.min_spin_box.setValue(self.ring.config.min_value) + + self.ui.max_spin_box.setRange(-1e6, 1e6) + self.ui.max_spin_box.setValue(self.ring.config.max_value) + self._update_min_max() + + self.ui.device_combo_box.setEditable(True) + self.ui.signal_combo_box.setEditable(True) + + device, signal = self.ring.config.device, self.ring.config.signal + if device: + self.ui.device_combo_box.set_device(device) + if signal: + for i in range(self.ui.signal_combo_box.count()): + data_item = self.ui.signal_combo_box.itemData(i) + if data_item and data_item.get("obj_name") == signal: + self.ui.signal_combo_box.setCurrentIndex(i) + break + + # Config values + self.ui.start_angle_spin_box.setValue(self.ring.config.start_position) + self.ui.direction_combo_box.setCurrentIndex(0 if self.ring.config.direction == -1 else 1) + self.ui.line_width_spin_box.setRange(1, 100) + self.ui.line_width_spin_box.setValue(self.ring.config.line_width) + + # Colors + self.ui.ring_color_button.set_color(self.ring.color) + self.ui.color_sync_button.setCheckable(True) + self.ui.color_sync_button.setChecked(self.ring.config.link_colors) + + # Set initial button state based on link_colors + if self.ring.config.link_colors: + self.ui.color_sync_button.setIcon(material_icon("link")) + self.ui.color_sync_button.setToolTip( + "Colors are linked - background derives from main color" + ) + self.ui.background_color_button.setEnabled(False) + self.ui.background_color_label.setEnabled(False) + # Trigger sync to ensure background color is derived from main color + self.ring.set_color(self.ring.config.color) + self.ui.background_color_button.set_color(self.ring.background_color) + else: + self.ui.color_sync_button.setIcon(material_icon("link_off")) + self.ui.color_sync_button.setToolTip( + "Colors are unlinked - set background independently" + ) + self.ui.background_color_button.setEnabled(True) + self.ui.background_color_label.setEnabled(True) + self.ui.background_color_button.set_color(self.ring.background_color) + + self.ui.color_sync_button.toggled.connect(self._toggle_color_link) + + # -------------------------------------------------------- + + def _toggle_color_link(self, checked: bool): + """Toggle the color linking between main and background color""" + self.ring.config.link_colors = checked + + # Update button icon and tooltip based on state + if checked: + self.ui.color_sync_button.setIcon(material_icon("link")) + self.ui.color_sync_button.setToolTip( + "Colors are linked - background derives from main color" + ) + # Trigger background color update by calling set_color + self.ring.set_color(self.ring.config.color) + # Update UI to show the new background color + self.ui.background_color_button.set_color(self.ring.background_color) + else: + self.ui.color_sync_button.setIcon(material_icon("link_off")) + self.ui.color_sync_button.setToolTip( + "Colors are unlinked - set background independently" + ) + + # Enable/disable background color controls based on link state + self.ui.background_color_button.setEnabled(not checked) + self.ui.background_color_label.setEnabled(not checked) + + def _on_ring_color_changed(self, color: QColor): + """Handle ring color changes and update background if colors are linked""" + self.ring.set_color(color) + # If colors are linked, update the background color button to show the new derived color + if self.ring.config.link_colors: + self.ui.background_color_button.set_color(self.ring.background_color) + + def _update_min_max(self): + self.ui.value_spin_box.setRange(self.ui.min_spin_box.value(), self.ui.max_spin_box.value()) + self.ring.set_min_max_values(self.ui.min_spin_box.value(), self.ui.max_spin_box.value()) + + def _update_direction(self, index: int): + self.ring.config.direction = -1 if index == 0 else 1 + self.ring.update() + + @SafeSlot(str) + def _on_device_changed(self, device: str): + signal = self.ui.signal_combo_box.get_signal_name() + self.ring.set_update("device", device=device, signal=signal) + self.ring.config.device = device + + @SafeSlot(str) + def _on_signal_changed(self, signal: str): + device = self.ui.device_combo_box.currentText() + signal = self.ui.signal_combo_box.get_signal_name() + if not device or device not in self.container.bec_dispatcher.client.device_manager.devices: + return + self.ring.set_update("device", device=device, signal=signal) + self.ring.config.signal = signal + + def _unify_mode_string(self, mode: str) -> str: + """Convert mode string to a unified format""" + mode = mode.lower() + if mode == "scan progress": + return "scan" + if mode == "device readback": + return "device" + return mode + + def _get_display_mode_string(self, mode: str) -> str: + """Convert mode string to display format""" + match mode: + case "manual": + return "Manual" + case "scan": + return "Scan Progress" + case "device": + return "Device Readback" + return mode.capitalize() + + def _update_mode(self, mode: str): + """Update the ring's mode based on combo box selection""" + mode = self._unify_mode_string(mode) + match mode: + case "manual": + self.ring.set_update("manual") + case "scan": + self.ring.set_update("scan") + case "device": + self.ring.set_update("device", device=self.ui.device_combo_box.currentText()) + self._set_widget_mode_enabled(mode) + + def _set_widget_mode_enabled(self, mode: str): + """Show/hide controls based on the current mode""" + mode = self._unify_mode_string(mode) + self.ui.device_combo_box.setEnabled(mode == "device") + self.ui.signal_combo_box.setEnabled(mode == "device") + self.ui.device_label.setEnabled(mode == "device") + self.ui.signal_label.setEnabled(mode == "device") + self.ui.min_label.setEnabled(mode in ["manual", "device"]) + self.ui.max_label.setEnabled(mode in ["manual", "device"]) + self.ui.value_label.setEnabled(mode == "manual") + self.ui.value_spin_box.setEnabled(mode == "manual") + self.ui.min_spin_box.setEnabled(mode in ["manual", "device"]) + self.ui.max_spin_box.setEnabled(mode in ["manual", "device"]) + + def _delete_self(self): + """Delete this ring from the container""" + if self.ring in self.container.rings: + self.container.rings.remove(self.ring) + self.ring.deleteLater() + + self.cleanup() + + def cleanup(self): + """Cleanup the card widget""" + self.ui.device_combo_box.close() + self.ui.device_combo_box.deleteLater() + self.ui.signal_combo_box.close() + self.ui.signal_combo_box.deleteLater() + self.close() + self.deleteLater() + + +# ============================================================ +# Ring settings widget +# ============================================================ + + +class RingSettings(SettingWidget): + def __init__( + self, parent=None, target_widget: RingProgressBar | None = None, popup=False, **kwargs + ): + super().__init__(parent=parent, **kwargs) + + self.setProperty("skip_settings", True) + self.target_widget = target_widget + self.popup = popup + if not target_widget: + return + self.container: RingProgressContainerWidget = target_widget.ring_progress_bar + self.original_num_bars = len(self.container.rings) + self.original_configs = [ring.config.model_dump() for ring in self.container.rings] + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + add_button = QPushButton(material_icon("add"), "Add Ring") + add_button.clicked.connect(self.add_ring) + + self.center_label_edit = QLineEdit(self.container.center_label.text()) + self.center_label_edit.setPlaceholderText("Center Label") + self.center_label_edit.textChanged.connect(self._update_center_label) + + self.colormap_toggle = QPushButton() + self.colormap_toggle.setCheckable(True) + self.colormap_toggle.setIcon(material_icon("palette")) + self.colormap_toggle.setToolTip( + f"Colormap mode is {'enabled' if self.container.color_map else 'disabled'}" + ) + self.colormap_toggle.toggled.connect(self._toggle_colormap_mode) + + self.colormap_button = BECColorMapWidget(parent=self) + self.colormap_button.setToolTip("Set a global colormap for all rings") + self.colormap_button.colormap_changed_signal.connect(self._set_global_colormap) + + toolbar = QHBoxLayout() + + toolbar.addWidget(add_button) + toolbar.addWidget(self.center_label_edit) + + toolbar.addStretch() + toolbar.addWidget(self.colormap_toggle) + toolbar.addWidget(self.colormap_button) + + layout.addLayout(toolbar) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + + self.cards_container = QWidget() + self.cards_layout = QVBoxLayout(self.cards_container) + self.cards_layout.setSpacing(10) + self.cards_layout.addStretch() + + self.scroll.setWidget(self.cards_container) + layout.addWidget(self.scroll) + + self.refresh_from_container() + self.original_label = self.container.center_label.text() + + def sizeHint(self) -> QSize: + return QSize(720, 520) + + def refresh_from_container(self): + if not self.container: + return + + for ring in self.container.rings: + card = RingCardWidget(ring, self.container) + self.cards_layout.insertWidget(self.cards_layout.count() - 1, card) + + if self.container.color_map: + self.colormap_button.colormap = self.container.color_map + self.colormap_toggle.setChecked(bool(self.container.color_map)) + + @SafeSlot() + def add_ring(self): + if not self.container: + return + self.container.add_ring() + ring = self.container.rings[len(self.container.rings) - 1] + if ring: + card = RingCardWidget(ring, self.container) + self.cards_layout.insertWidget(self.cards_layout.count() - 1, card) + + # If a global colormap is set, apply it + if self.container.color_map: + self._toggle_colormap_mode(bool(self.container.color_map)) + + @SafeSlot(str) + def _update_center_label(self, text: str): + if not self.container: + return + self.container.center_label.setText(text) + + @SafeSlot(bool) + def _toggle_colormap_mode(self, enabled: bool): + self.colormap_toggle.setToolTip(f"Colormap mode is {'enabled' if enabled else 'disabled'}") + if enabled: + colormap = self.colormap_button.colormap + self._set_global_colormap(colormap) + else: + self.container.color_map = "" + for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch + widget = self.cards_layout.itemAt(i).widget() + if not isinstance(widget, RingCardWidget): + continue + widget.ui.ring_color_button.setEnabled(not enabled) + widget.ui.ring_color_button.setToolTip( + "Disabled in colormap mode" if enabled else "Set the ring color" + ) + widget.ui.ring_color_label.setEnabled(not enabled) + widget.ui.background_color_button.setEnabled( + not enabled and not widget.ring.config.link_colors + ) + widget.ui.color_sync_button.setEnabled(not enabled) + + @SafeSlot(str) + def _set_global_colormap(self, colormap: str): + if not self.container: + return + self.container.set_colors_from_map(colormap) + + # Update all ring card color buttons to reflect the new colors + for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch + widget = self.cards_layout.itemAt(i).widget() + if not isinstance(widget, RingCardWidget): + continue + widget.ui.ring_color_button.set_color(widget.ring.color) + if widget.ring.config.link_colors: + widget.ui.background_color_button.set_color(widget.ring.background_color) + + @SafeSlot() + def accept_changes(self): + if not self.container: + return + + self.original_configs = [ring.config.model_dump() for ring in self.container.rings] + + for i, ring in enumerate(self.container.rings): + ring.setGeometry(self.container.rect()) + ring.gap = self.container.gap * i + ring.show() # Ensure ring is visible + ring.raise_() # Bring ring to front + + self.container.center_label.setText(self.center_label_edit.text()) + self.original_label = self.container.center_label.text() + self.original_num_bars = len(self.container.rings) + + self.container.update() + + def cleanup(self): + """ + Cleanup the settings widget. + """ + # Remove any rings that were added but not applied + if not self.container: + return + if len(self.container.rings) > self.original_num_bars: + remove_rings = self.container.rings[self.original_num_bars :] + for ring in remove_rings: + self.container.rings.remove(ring) + ring.deleteLater() + rings_to_add = max(0, self.original_num_bars - len(self.container.rings)) + for _ in range(rings_to_add): + self.container.add_ring() + + # apply original configs to all rings + for i, ring in enumerate(self.container.rings): + ring.config = ring.config.model_validate(self.original_configs[i]) + + for i in range(self.cards_layout.count()): + item = self.cards_layout.itemAt(i) + if not item or not item.widget(): + continue + widget: RingCardWidget = item.widget() + widget.cleanup() + self.container.update() + self.container.center_label.setText(self.original_label) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui b/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui new file mode 100644 index 000000000..fd3c4963c --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui @@ -0,0 +1,235 @@ + + + Form + + + + 0 + 0 + 731 + 199 + + + + Form + + + + + + Data + + + + + + + + + + + + Value + + + + + + + Min + + + + + + + Max + + + + + + + + + + Signal + + + + + + + + + + + + + Device + + + + + + + Qt::Orientation::Horizontal + + + + + + + + + + Config + + + + + + Qt::Orientation::Horizontal + + + + + + + ° + + + 360 + + + 90 + + + + + + + Line Width + + + + + + + + Clockwise + + + + + Counter-clockwise + + + + + + + + Direction + + + + + + + 12 + + + + + + + Start Angle + + + + + + + + + + + + + Background Color + + + + + + + Ring Color + + + + + + + ... + + + + + + + + + + + ColorButtonNative + +
color_button_native
+
+ + DeviceComboBox + +
device_combo_box
+
+ + SignalComboBox + +
signal_combo_box
+
+
+ + + + device_combo_box + currentTextChanged(QString) + signal_combo_box + set_device(QString) + + + 209 + 133 + + + 213 + 153 + + + + + device_combo_box + device_reset() + signal_combo_box + reset_selection() + + + 248 + 135 + + + 250 + 147 + + + + +
diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py index 2ebce2c80..2fada11db 100644 --- a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py +++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py @@ -146,6 +146,9 @@ def __init__( self.layout.addWidget(self.ui) self.setLayout(self.layout) self.progressbar = self.ui.progressbar + self._show_elapsed_time = self.ui.elapsed_time_label.isVisible() + self._show_remaining_time = self.ui.remaining_time_label.isVisible() + self._show_source_label = self.ui.source_label.isVisible() self.connect_to_queue() self._progress_source = None @@ -222,30 +225,33 @@ def on_progress_update(self, msg_content: dict, metadata: dict): @SafeProperty(bool) def show_elapsed_time(self): - return self.ui.elapsed_time_label.isVisible() + return self._show_elapsed_time @show_elapsed_time.setter def show_elapsed_time(self, value): + self._show_elapsed_time = value self.ui.elapsed_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_remaining_time(self): - return self.ui.remaining_time_label.isVisible() + return self._show_remaining_time @show_remaining_time.setter def show_remaining_time(self, value): + self._show_remaining_time = value self.ui.remaining_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_source_label(self): - return self.ui.source_label.isVisible() + return self._show_source_label @show_source_label.setter def show_source_label(self, value): + self._show_source_label = value self.ui.source_label.setVisible(value) def update_labels(self): diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 473ed3ef3..aa37cc70c 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -52,6 +52,7 @@ def __init__( ) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + self._toolbar_hidden = False # Set up the toolbar self.set_toolbar() @@ -105,7 +106,7 @@ def set_toolbar(self): @Property(bool) def hide_toolbar(self): """Property to hide the BEC Queue toolbar.""" - return not self.toolbar.isVisible() + return self._toolbar_hidden @hide_toolbar.setter def hide_toolbar(self, hide: bool): @@ -124,6 +125,7 @@ def _hide_toolbar(self, hide: bool): Args: hide(bool): Whether to hide the toolbar. """ + self._toolbar_hidden = hide self.toolbar.setVisible(not hide) def refresh_queue(self): @@ -207,7 +209,7 @@ def format_item(self, content: str, status=False) -> QTableWidgetItem: if not content or not isinstance(content, str): content = "" item = QTableWidgetItem(content) - item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter) # item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if status: @@ -252,8 +254,15 @@ def _create_abort_button(self, scan_id: str) -> AbortButton: abort_button.button.setIcon( material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False) ) - abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ") - abort_button.button.setFlat(True) + abort_button.setStyleSheet( + """ + QPushButton { + background-color: transparent; + border: none; + } + """ + ) + return abort_button def delete_selected_row(self): diff --git a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py index cd21e9b6b..e1b8948de 100644 --- a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py +++ b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py @@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget): PLUGIN = True CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"] - USER_ACCESS = ["get_server_state", "remove"] + USER_ACCESS = ["get_server_state", "remove", "attach", "detach", "screenshot"] service_update = Signal(BECServiceInfoContainer) bec_core_state = Signal(str) @@ -315,10 +315,10 @@ def cleanup(self): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") main_window = BECStatusBox() main_window.show() sys.exit(app.exec()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index 9b28607c1..9aa0e789f 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -1,6 +1,4 @@ import os -import re -from functools import partial from typing import Callable import bec_lib @@ -11,23 +9,17 @@ from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_qthemes import material_icon from pyqtgraph import SignalProxy -from qtpy.QtCore import QSize, QThreadPool, Signal -from qtpy.QtWidgets import ( - QFileDialog, - QListWidget, - QListWidgetItem, - QToolButton, - QVBoxLayout, - QWidget, -) +from qtpy.QtCore import QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -61,7 +53,8 @@ def __init__( self._q_threadpool = QThreadPool() self.ui = None self.init_ui() - self.dev_list: QListWidget = self.ui.device_list + self.dev_list = ListOfExpandableFrames(self, DeviceItem) + self.ui.verticalLayout.addWidget(self.dev_list) self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) self.proxy_device_update = SignalProxy( self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list @@ -116,7 +109,7 @@ def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ) def _create_add_dialog(self): - dialog = DeviceConfigDialog(parent=self, device=None, action="add") + dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add") dialog.open() def on_device_update(self, action: ConfigAction, content: dict) -> None: @@ -134,25 +127,15 @@ def on_device_update(self, action: ConfigAction, content: dict) -> None: def init_device_list(self): self.dev_list.clear() - self._device_items: dict[str, QListWidgetItem] = {} with RPCRegister.delayed_broadcast(): for device, device_obj in self.dev.items(): self._add_item_to_list(device, device_obj) def _add_item_to_list(self, device: str, device_obj): - def _updatesize(item: QListWidgetItem, device_item: DeviceItem): - device_item.adjustSize() - item.setSizeHint(QSize(device_item.width(), device_item.height())) - logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}") - - def _remove_item(item: QListWidgetItem): - self.dev_list.takeItem(self.dev_list.row(item)) - del self._device_items[device] - self.dev_list.sortItems() - - item = QListWidgetItem(self.dev_list) - device_item = DeviceItem( + + _, device_item = self.dev_list.add_item( + id=device, parent=self, device=device, devices=self.dev, @@ -160,18 +143,11 @@ def _remove_item(item: QListWidgetItem): config_helper=self._config_helper, q_threadpool=self._q_threadpool, ) - device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) - device_item.imminent_deletion.connect(partial(_remove_item, item)) + self.editing_enabled.connect(device_item.set_editable) self.device_update.connect(device_item.config_update) tooltip = self.dev[device]._config.get("description", "") device_item.setToolTip(tooltip) - device_item.broadcast_size_hint.connect(item.setSizeHint) - item.setSizeHint(device_item.sizeHint()) - - self.dev_list.setItemWidget(item, device_item) - self.dev_list.addItem(item) - self._device_items[device] = item @SafeSlot(dict, dict) def scan_status_changed(self, scan_info: dict, _: dict): @@ -200,20 +176,11 @@ def update_device_list(self, *_) -> None: Either way, the function will filter the devices based on the filter input text and update the device list. """ - filter_text = self.ui.filter_input.text() for device in self.dev: - if device not in self._device_items: + if device not in self.dev_list: # it is possible the device has just been added to the config self._add_item_to_list(device, self.dev[device]) - try: - self.regex = re.compile(filter_text, re.IGNORECASE) - except re.error: - self.regex = None # Invalid regex, disable filtering - for device in self.dev: - self._device_items[device].setHidden(False) - return - for device in self.dev: - self._device_items[device].setHidden(not self.regex.search(device)) + self.dev_list.update_filter(self.ui.filter_input.text()) @SafeSlot() def _load_from_file(self): @@ -242,10 +209,10 @@ def cleanup(self): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = DeviceBrowser() widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.ui b/bec_widgets/widgets/services/device_browser/device_browser.ui index 9a2d4ce28..0903854c8 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.ui +++ b/bec_widgets/widgets/services/device_browser/device_browser.ui @@ -1,93 +1,90 @@ - Form - - - - 0 - 0 - 406 - 500 - - - - Form - - - - - - Device Browser - - - - - - - - Filter - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - ... - - - - - - - ... - - - - - - - ... - - - - - - - - - - - - + Form + + + + 0 + 0 + 406 + 500 + - - warning + + Form - - - - - - + + + + + Device Browser + + + + + + + + Filter + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + + warning + + + + + + + - - - - - - + + + \ No newline at end of file diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index 4a469dbba..990f030a8 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -34,7 +34,13 @@ def __init__( @SafeSlot() def run(self): try: - if self.action in ["add", "update", "remove"]: + if self.action == "set": + self._process( + {"action": self.action, "config": self.config, "wait_for_response": False} + ) + elif self.action == "cancel": + self._process_cancel() + elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( "Must be updating a device or be supplied a name for a new device" @@ -57,6 +63,9 @@ def process_simple_action(self, dev_name: str, action: ConfigAction | None = Non "config": {dev_name: self.config}, "wait_for_response": False, } + self._process(req_args) + + def _process(self, req_args: dict): timeout = ( self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20 ) @@ -66,6 +75,13 @@ def process_simple_action(self, dev_name: str, action: ConfigAction | None = Non self.config_helper.handle_update_reply(reply, RID, timeout) logger.info("Done updating config!") + def _process_cancel(self): + logger.info("Cancelling ongoing configuration operation") + self.config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Done cancelling configuration operation") + def process_remove_readd(self, dev_name: str): logger.info(f"Removing and readding device: {dev_name}") self.process_simple_action(dev_name, "remove") diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index 1ffd8fbb5..704997b44 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -5,12 +5,14 @@ from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import field_validator -from qtpy.QtCore import QSize, Qt, QThreadPool, Signal +from pydantic import BaseModel, field_validator +from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, + QComboBox, QDialog, QDialogButtonBox, + QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, @@ -19,6 +21,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) @@ -29,6 +32,8 @@ logger = bec_logger.logger +_StdBtn = QDialogButtonBox.StandardButton + def _try_literal_eval(value: str): if value == "": @@ -39,79 +44,36 @@ def _try_literal_eval(value: str): raise ValueError(f"Entered config value {value} is not a valid python value!") from e -class DeviceConfigDialog(BECWidget, QDialog): +class DeviceConfigDialog(QDialog): RPC = False applied = Signal() + accepted_data = Signal(dict) def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update", "add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs ): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ self._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item super().__init__(parent=parent, **kwargs) - self._config_helper = config_helper or ConfigHelper( - self.client.connector, self.client._service_name, self.client.device_manager - ) - self._device = device - self._action: Literal["update", "add"] = action - self._q_threadpool = threadpool or QThreadPool() - self.setWindowTitle(f"Edit config for: {device}") + self._container = QStackedLayout() - self._container.setStackingMode(QStackedLayout.StackAll) + self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - user_warning = QLabel( - "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" - "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." - ) - user_warning.setWordWrap(True) - user_warning.setStyleSheet("QLabel { color: red; }") - self._layout.addWidget(user_warning) - self.get_bec_shortcuts() + self._data = {} self._add_form() - if self._action == "update": - self._form._validity.setVisible(False) - else: - self._set_schema_to_check_devices() - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) self._add_overlay() self._add_buttons() - + self.setWindowTitle("Add new device") self.setLayout(self._container) - self._form.validate_form() self._overlay_widget.setVisible(False) + self._form._validity.setVisible(True) + self._connect_form() - def _set_schema_to_check_devices(self): - class _NameValidatedConfigModel(DeviceConfigModel): - @field_validator("name") - @staticmethod - def _validate_name(value: str, *_): - if not value.isidentifier(): - raise ValueError( - f"Invalid device name: {value}. Device names must be valid Python identifiers." - ) - if value in self.dev: - raise ValueError(f"A device with name {value} already exists!") - return value - - self._form.set_schema(_NameValidatedConfigModel) + def _connect_form(self): + self._form.validity_proc.connect(self.enable_buttons_for_validity) + self._form.validate_form() def _add_form(self): self._form_widget = QWidget() @@ -119,16 +81,6 @@ def _add_form(self): self._form = DeviceConfigForm() self._layout.addWidget(self._form) - for row in self._form.enumerate_form_widgets(): - if ( - row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE - and self._action == "update" - ): - row.widget._set_pretty_display() - - if self._action == "update" and self._device in self.dev: - self._fetch_config() - self._fill_form() self._container.addWidget(self._form_widget) def _add_overlay(self): @@ -145,21 +97,12 @@ def _add_overlay(self): self._container.addWidget(self._overlay_widget) def _add_buttons(self): - self.button_box = QDialogButtonBox( - QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply) + self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel) + self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self._layout.addWidget(self.button_box) - def _fetch_config(self): - if ( - self.client.device_manager is not None - and self._device in self.client.device_manager.devices - ): - self._initial_config = self.client.device_manager.devices.get(self._device)._config - def _fill_form(self): self._form.set_data(DeviceConfigModel.model_validate(self._initial_config)) @@ -190,12 +133,16 @@ def updated_config(self): @SafeSlot(bool) def enable_buttons_for_validity(self, valid: bool): # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - for button in [ - self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok] - ]: + for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]: button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) + def _process_action(self): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data + @SafeSlot(popup_error=True) def apply(self): self._process_action() @@ -206,10 +153,138 @@ def accept(self): self._process_action() return super().accept() + +class EpicsMotorConfig(BaseModel): + prefix: str + + +class EpicsSignalROConfig(BaseModel): + read_pv: str + + +class EpicsSignalConfig(BaseModel): + read_pv: str + write_pv: str | None = None + + +class PresetClassDeviceConfigDialog(DeviceConfigDialog): + def __init__(self, *, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._device_models = { + "EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}), + "EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + "Custom": (None, {}), + } + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) + + def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): + for field_name, (value, editable) in constraints.items(): + if (widget := self._form.widget_dict.get(field_name)) is not None: + widget.setValue(value) + if not editable: + widget._set_pretty_display() + + def _replace_form(self, deviceconfig_cls_key): + self._form.deleteLater() + if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None: + devmodel, params = devmodel_params + else: + devmodel, params = None, {} + self._form = DeviceConfigForm(class_deviceconfig_item=devmodel) + self._apply_constraints(params) + self._layout.insertWidget(1, self._form) + self._connect_form() + + def _create_selection_box(self): + layout = QHBoxLayout() + self._selection_box = QComboBox() + self._selection_box.addItems(list(self._device_models.keys())) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + +class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): + def __init__( + self, + *, + parent=None, + device: str | None = None, + config_helper: ConfigHelper | None = None, + action: Literal["update"] | Literal["add"] = "update", + threadpool: QThreadPool | None = None, + **kwargs, + ): + """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model + for device specification in bec_lib.atlas_models. + + Args: + parent (QObject): the parent QObject + device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. + config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. + action (Literal["update", "add"]): the action which the form should perform on application or acceptance. + """ + self._device = device + self._q_threadpool = threadpool or QThreadPool() + self._config_helper = config_helper or ConfigHelper( + self.client.connector, self.client._service_name + ) + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + self._action: Literal["update", "add"] = action + user_warning = QLabel( + "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" + "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." + ) + user_warning.setWordWrap(True) + user_warning.setStyleSheet("QLabel { color: red; }") + self._layout.insertWidget(0, user_warning) + self.setWindowTitle( + f"Edit config for: {device}" if action == "update" else "Add new device" + ) + + if self._action == "update": + self._modify_for_update() + self._form.validity_proc.disconnect(self.enable_buttons_for_validity) + else: + self._set_schema_to_check_devices() + # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved + # self._form._validity.setVisible(True) + + def _modify_for_update(self): + for row in self._form.enumerate_form_widgets(): + if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE: + row.widget._set_pretty_display() + if self._device in self.dev: + self._fetch_config() + self._fill_form() + self._form._validity.setVisible(False) + + def _set_schema_to_check_devices(self): + class _NameValidatedConfigModel(DeviceConfigModel): + @field_validator("name") + @staticmethod + def _validate_name(value: str, *_): + if not value.isidentifier(): + raise ValueError( + f"Invalid device name: {value}. Device names must be valid Python identifiers." + ) + if value in self.dev: + raise ValueError(f"A device with name {value} already exists!") + return value + + self._form.set_schema(_NameValidatedConfigModel) + + def _fetch_config(self): + if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore + self._initial_config = device._config + def _process_action(self): updated_config = self.updated_config() if self._action == "add": - if (name := updated_config.get("name")) in self.dev: + if self.dev is not None and (name := updated_config.get("name")) in self.dev: raise ValueError( f"Can't create a new device with the same name as already existing device {name}!" ) @@ -249,12 +324,10 @@ def update_error(self, e: Exception): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QApplication.processEvents() def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QApplication.processEvents() def main(): # pragma: no cover @@ -262,17 +335,17 @@ def main(): # pragma: no cover from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme dialog = None app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = QWidget() - widget.setLayout(QVBoxLayout()) + widget.setLayout(layout := QVBoxLayout()) device = QLineEdit() - widget.layout().addWidget(device) + layout.addWidget(device) def _destroy_dialog(*_): nonlocal dialog @@ -285,14 +358,14 @@ def accept(*args): def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DeviceConfigDialog(**kwargs) + kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} + dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() button = QPushButton("Show device dialog") - widget.layout().addWidget(button) + layout.addWidget(button) button.clicked.connect(_show_dialog) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 0b8c1aeb0..a783d9883 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -1,16 +1,20 @@ from __future__ import annotations +from functools import partial + from bec_lib.atlas_models import Device as DeviceConfigModel from pydantic import BaseModel from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, + DictFormItem, + FormItemSpec, ) @@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + def __init__( + self, + parent=None, + client=None, + pretty_display=False, + class_deviceconfig_item: type[BaseModel] | None = None, + **kwargs, + ): super().__init__( parent=parent, data_model=DeviceConfigModel, @@ -26,18 +37,28 @@ def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): client=client, **kwargs, ) + self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) self._widget_types["optional_bool"] = ( lambda spec: spec.item_type == bool | None, BoolFormItem, ) - self._validity.setVisible(False) + pred, _ = self._widget_types["dict"] + self._widget_types["dict"] = pred, self._custom_device_config_item + self._validity.setVisible(True) self._connect_to_theme_change() self.populate() def _post_init(self): ... + def _custom_device_config_item(self, spec: FormItemSpec): + if spec.name != "deviceConfig": + return DictFormItem + if self._class_deviceconfig_item is not None: + return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) + return DictFormItem + def set_pretty_display_theme(self, theme: str | None = None): if theme is None: theme = get_theme_name() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index def709eb2..45f233cb6 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -18,7 +18,7 @@ CommunicateConfigAction, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( DeviceConfigForm, @@ -35,9 +35,6 @@ class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( @@ -94,7 +91,7 @@ def _create_title_layout(self, title: str, icon: str): @SafeSlot() def _create_edit_dialog(self): - dialog = DeviceConfigDialog( + dialog = DirectUpdateDeviceConfigDialog( parent=self, device=self.device, config_helper=self._config_helper, diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py index 30e53ad23..56a92e04b 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py @@ -7,7 +7,6 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.ophyd_kind_util import Kind -from bec_widgets.widgets.containers.dock.dock import BECDock from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel @@ -16,17 +15,24 @@ class SignalDisplay(BECWidget, QWidget): def __init__( self, + parent=None, client=None, device: str = "", config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, - parent_dock: BECDock | None = None, **kwargs, ): """A widget to display all the signals from a given device, and allow getting a fresh reading.""" - super().__init__(client, config, gui_id, theme_update, parent_dock, **kwargs) + super().__init__( + parent=parent, + client=client, + config=config, + gui_id=gui_id, + theme_update=theme_update, + **kwargs, + ) self.get_bec_shortcuts() self._layout = QVBoxLayout() self.setLayout(self._layout) @@ -74,6 +80,7 @@ def _populate(self): ]: self._content_layout.addWidget( SignalLabel( + parent=self, device=self._device, signal=sig, show_select_button=False, @@ -83,6 +90,7 @@ def _populate(self): else: self._content_layout.addWidget( SignalLabel( + parent=self, device=self._device, signal=self._device, show_select_button=False, @@ -110,10 +118,10 @@ def device(self, value: str): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = SignalDisplay(device="samx") widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py b/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py index 1a417f210..687d0b9cc 100644 --- a/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py +++ b/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py @@ -177,12 +177,10 @@ def _add_overlay(self): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QtWidgets.QApplication.processEvents() def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QtWidgets.QApplication.processEvents() def _current_item_changed( self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 38a5b2744..2c19a1760 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -1,13 +1,19 @@ import datetime import importlib +import importlib.metadata import os +import re +from typing import Literal +from bec_qthemes import material_icon +from qtpy.QtCore import Signal from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection from bec_widgets.widgets.containers.explorer.explorer import Explorer +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget @@ -17,16 +23,19 @@ class IDEExplorer(BECWidget, QWidget): PLUGIN = True RPC = False + file_open_requested = Signal(str, str) + file_preview_requested = Signal(str, str) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._sections = set() + self._sections = [] # Use list to maintain order instead of set self.main_explorer = Explorer(parent=self) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.main_explorer) self.setLayout(layout) - self.sections = ["scripts"] + self.sections = ["scripts", "macros"] @SafeProperty(list) def sections(self): @@ -35,10 +44,16 @@ def sections(self): @sections.setter def sections(self, value): existing_sections = set(self._sections) - self._sections = set(value) - self._update_section_visibility(self._sections - existing_sections) + new_sections = set(value) + # Find sections to add, maintaining the order from the input value list + sections_to_add = [ + section for section in value if section in (new_sections - existing_sections) + ] + self._sections = list(value) # Store as ordered list + self._update_section_visibility(sections_to_add) def _update_section_visibility(self, sections): + # sections is now an ordered list, not a set for section in sections: self._add_section(section) @@ -46,15 +61,29 @@ def _add_section(self, section_name): match section_name.lower(): case "scripts": self.add_script_section() + case "macros": + self.add_macro_section() case _: pass + def _remove_section(self, section_name): + section = self.main_explorer.get_section(section_name.upper()) + if section: + self.main_explorer.remove_section(section) + self._sections.remove(section_name) + + def clear(self): + """Clear all sections from the explorer.""" + for section in reversed(self._sections): + self._remove_section(section) + def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) - section.expanded = False script_explorer = Explorer(parent=self) script_widget = ScriptTreeWidget(parent=self) + script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) + script_widget.file_selected.connect(self._emit_file_preview_scripts_local) local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) local_scripts_section.header_add_button.clicked.connect(self._add_local_script) local_scripts_section.set_widget(script_widget) @@ -67,24 +96,98 @@ def add_script_section(self): section.set_widget(script_explorer) self.main_explorer.add_section(section) - plugin_scripts_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts") - break + plugin_scripts_dir = self._get_plugin_dir("scripts") if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return - shared_script_section = CollapsibleSection(title="Shared", parent=self) + shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_script_section.setToolTip("Shared scripts (read-only)") shared_script_widget = ScriptTreeWidget(parent=self) shared_script_section.set_widget(shared_script_widget) shared_script_widget.set_directory(plugin_scripts_dir) script_explorer.add_section(shared_script_section) - # macros_section = CollapsibleSection("MACROS", indentation=0) - # macros_section.set_widget(QLabel("Macros will be implemented later")) - # self.main_explorer.add_section(macros_section) + shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) + shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) + + def add_macro_section(self): + section = CollapsibleSection( + parent=self, + title="MACROS", + indentation=0, + show_add_button=True, + tooltip="Macros are reusable functions that can be called from scripts or the console.", + ) + section.header_add_button.setIcon( + material_icon("refresh", size=(20, 20), convert_to_pixmap=False) + ) + section.header_add_button.setToolTip("Reload all macros") + section.header_add_button.clicked.connect(self._reload_macros) + + macro_explorer = Explorer(parent=self) + macro_widget = MacroTreeWidget(parent=self) + macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local) + macro_widget.macro_selected.connect(self._emit_file_preview_macros_local) + local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_macros_section.header_add_button.clicked.connect(self._add_local_macro) + local_macros_section.set_widget(macro_widget) + local_macro_dir = self.client._service_config.model.user_macros.base_path + if not os.path.exists(local_macro_dir): + os.makedirs(local_macro_dir) + macro_widget.set_directory(local_macro_dir) + macro_explorer.add_section(local_macros_section) + + section.set_widget(macro_explorer) + self.main_explorer.add_section(section) + + plugin_macros_dir = self._get_plugin_dir("macros") + + if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): + return + shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_macro_section.setToolTip("Shared macros (read-only)") + shared_macro_widget = MacroTreeWidget(parent=self) + shared_macro_section.set_widget(shared_macro_widget) + shared_macro_widget.set_directory(plugin_macros_dir) + macro_explorer.add_section(shared_macro_section) + shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) + shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + + def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None: + """Get the path to the specified directory within the BEC plugin. + + Returns: + The path to the specified directory, or None if not found. + """ + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + return os.path.join(plugin.__path__[0], dir_name) + return None + + def _emit_file_open_scripts_local(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/local") + + def _emit_file_preview_scripts_local(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/local") + + def _emit_file_open_scripts_shared(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/shared") + + def _emit_file_preview_scripts_shared(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/shared") + + def _emit_file_open_macros_local(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/local") + + def _emit_file_preview_macros_local(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/local") + + def _emit_file_open_macros_shared(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/shared") + + def _emit_file_preview_macros_shared(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/shared") def _add_local_script(self): """Show a dialog to enter the name of a new script and create it.""" @@ -136,6 +239,134 @@ def _add_local_script(self): # Show error if file creation failed QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}") + def _add_local_macro(self): + """Show a dialog to enter the name of a new macro function and create it.""" + + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + + local_macro_dir = macro_dir_section.content_widget.directory + + # Prompt user for function name + function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:") + + if not ok or not function_name: + return # User cancelled or didn't enter a name + + # Sanitize function name + function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name) + if not function_name or function_name[0].isdigit(): + QMessageBox.warning( + self, "Invalid Name", "Function name must be a valid Python identifier." + ) + return + + # Create filename based on function name + filename = f"{function_name}.py" + file_path = os.path.join(local_macro_dir, filename) + + # Check if file already exists + if os.path.exists(file_path): + response = QMessageBox.question( + self, + "File exists", + f"The file '{filename}' already exists. Do you want to overwrite it?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if response != QMessageBox.StandardButton.Yes: + return # User chose not to overwrite + + try: + # Create the file with a macro function template + with open(file_path, "w", encoding="utf-8") as f: + f.write( + f'''""" +{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + + +def {function_name}(): + """ + Description of what this macro does. + + Add your macro implementation here. + """ + print("Executing macro: {function_name}") + # TODO: Add your macro code here + pass +''' + ) + + # Refresh the macro tree to show the new function + macro_dir_section.content_widget.refresh() + + except Exception as e: + # Show error if file creation failed + QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}") + + def _reload_macros(self): + """Reload all macros using the BEC client.""" + try: + if hasattr(self.client, "macros"): + self.client.macros.load_all_user_macros() + + # Refresh the macro tree widgets to show updated functions + target_section = self.main_explorer.get_section("MACROS") + if target_section and hasattr(target_section, "content_widget"): + local_section = target_section.content_widget.get_section("Local") + if local_section and hasattr(local_section, "content_widget"): + local_section.content_widget.refresh() + + shared_section = target_section.content_widget.get_section("Shared") + if shared_section and hasattr(shared_section, "content_widget"): + shared_section.content_widget.refresh() + + QMessageBox.information( + self, "Reload Macros", "Macros have been reloaded successfully." + ) + else: + QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + + def refresh_macro_file(self, file_path: str): + """Refresh a single macro file in the tree widget. + + Args: + file_path: Path to the macro file that was updated + """ + target_section = self.main_explorer.get_section("MACROS") + if not target_section or not hasattr(target_section, "content_widget"): + return + + # Determine if this is a local or shared macro based on the file path + local_section = target_section.content_widget.get_section("Local") + shared_section = target_section.content_widget.get_section("Shared") + + # Check if file belongs to local macros directory + if ( + local_section + and hasattr(local_section, "content_widget") + and hasattr(local_section.content_widget, "directory") + ): + local_macro_dir = local_section.content_widget.directory + if local_macro_dir and file_path.startswith(local_macro_dir): + local_section.content_widget.refresh_file_item(file_path) + return + + # Check if file belongs to shared macros directory + if ( + shared_section + and hasattr(shared_section, "content_widget") + and hasattr(shared_section.content_widget, "directory") + ): + shared_macro_dir = shared_section.content_widget.directory + if shared_macro_dir and file_path.startswith(shared_macro_dir): + shared_section.content_widget.refresh_file_item(file_path) + return + if __name__ == "__main__": from qtpy.QtWidgets import QApplication diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py index ce99a35e2..2c1c60bbd 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py @@ -1,7 +1,7 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause - from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -20,6 +20,8 @@ def __init__(self): self._form_editor = None def createWidget(self, parent): + if parent is None: + return QWidget() t = IDEExplorer(parent) return t diff --git a/bec_widgets/widgets/utility/logpanel/logpanel.py b/bec_widgets/widgets/utility/logpanel/logpanel.py index 76aca47d7..ad5dee294 100644 --- a/bec_widgets/widgets/utility/logpanel/logpanel.py +++ b/bec_widgets/widgets/utility/logpanel/logpanel.py @@ -35,7 +35,7 @@ ) from bec_widgets.utils.bec_connector import BECConnector -from bec_widgets.utils.colors import get_theme_palette, set_theme +from bec_widgets.utils.colors import apply_theme, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.text_box.text_box import TextBox from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin @@ -544,7 +544,7 @@ def cleanup(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = LogPanel() widget.show() diff --git a/bec_widgets/widgets/utility/signal_label/signal_label.py b/bec_widgets/widgets/utility/signal_label/signal_label.py index ebf80c9a2..529d175b2 100644 --- a/bec_widgets/widgets/utility/signal_label/signal_label.py +++ b/bec_widgets/widgets/utility/signal_label/signal_label.py @@ -8,7 +8,6 @@ from bec_lib.device import Device, Signal from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import Qt from qtpy.QtCore import Signal as QSignal from qtpy.QtWidgets import ( QApplication, @@ -483,6 +482,11 @@ def _update_label(self): self._custom_label if self._custom_label else f"{self._default_label}:" ) + def cleanup(self): + self.disconnect_device() + self._device_obj = None + super().cleanup() + if __name__ == "__main__": app = QApplication(sys.argv) @@ -490,6 +494,7 @@ def _update_label(self): w.setLayout(QVBoxLayout()) w.layout().addWidget( SignalLabel( + parent=w, device="samx", signal="readback", custom_label="custom label:", @@ -497,7 +502,9 @@ def _update_label(self): show_select_button=False, ) ) - w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True)) + w.layout().addWidget( + SignalLabel(parent=w, device="samy", signal="readback", show_default_units=True) + ) l = SignalLabel() l.device = "bpm4i" l.signal = "bpm4i" diff --git a/bec_widgets/widgets/utility/spinner/spinner.py b/bec_widgets/widgets/utility/spinner/spinner.py index 099804af7..ab5dadd15 100644 --- a/bec_widgets/widgets/utility/spinner/spinner.py +++ b/bec_widgets/widgets/utility/spinner/spinner.py @@ -49,7 +49,7 @@ def rotate(self): def paintEvent(self, event): painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) size = min(self.width(), self.height()) rect = QRect(0, 0, size, size) @@ -63,14 +63,14 @@ def paintEvent(self, event): rect.adjust(line_width, line_width, -line_width, -line_width) # Background arc - painter.setPen(QPen(background_color, line_width, Qt.SolidLine)) + painter.setPen(QPen(background_color, line_width, Qt.PenStyle.SolidLine)) adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height()) painter.drawArc(adjusted_rect, 0, 360 * 16) if self._started: # Foreground arc - pen = QPen(color, line_width, Qt.SolidLine) - pen.setCapStyle(Qt.RoundCap) + pen = QPen(color, line_width, Qt.PenStyle.SolidLine) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) proportion = 1 / 4 angle_span = int(proportion * 360 * 16) diff --git a/bec_widgets/widgets/utility/toggle/toggle.py b/bec_widgets/widgets/utility/toggle/toggle.py index c69ee48ba..2ec60d203 100644 --- a/bec_widgets/widgets/utility/toggle/toggle.py +++ b/bec_widgets/widgets/utility/toggle/toggle.py @@ -4,6 +4,8 @@ from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import QApplication, QWidget +from bec_widgets.utils.error_popups import SafeConnect, SafeSlot + class ToggleSwitch(QWidget): """ @@ -20,10 +22,10 @@ def __init__(self, parent=None, checked=True): self.setFixedSize(40, 21) self._thumb_pos = QPointF(3, 2) # Use QPointF for the thumb position - self._active_track_color = QColor(33, 150, 243) - self._active_thumb_color = QColor(255, 255, 255) - self._inactive_track_color = QColor(200, 200, 200) - self._inactive_thumb_color = QColor(255, 255, 255) + theme = getattr(QApplication.instance(), "theme", None) + if theme: + SafeConnect(self, theme.theme_changed, self._update_theme_colors) + self._update_theme_colors() self._checked = checked self._track_color = self.inactive_track_color @@ -34,6 +36,16 @@ def __init__(self, parent=None, checked=True): self._animation.setEasingCurve(QEasingCurve.Type.OutBack) self.setProperty("checked", checked) + @SafeSlot(str) + def _update_theme_colors(self, _theme: str | None = None): + theme = getattr(QApplication.instance(), "theme", None) + colors = theme.colors if theme else {} + + self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243)) + self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255)) + self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200)) + self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255)) + @Property(bool) def checked(self): """ @@ -155,7 +167,20 @@ def minimumSizeHint(self): if __name__ == "__main__": # pragma: no cover + from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget + + from bec_widgets.utils.colors import apply_theme + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + app = QApplication(sys.argv) - window = ToggleSwitch() + apply_theme("dark") + widget = QWidget() + layout = QHBoxLayout(widget) + toggle = ToggleSwitch() + dark_mode_btn = DarkModeButton() + layout.addWidget(toggle) + layout.addWidget(dark_mode_btn) + window = QWidget() + window.setLayout(layout) window.show() sys.exit(app.exec()) diff --git a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py index e8f352e8d..840e56ad4 100644 --- a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +++ b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py @@ -5,7 +5,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme class DarkModeButton(BECWidget, QWidget): @@ -23,6 +23,7 @@ def __init__( **kwargs, ) -> None: super().__init__(parent=parent, client=client, gui_id=gui_id, theme_update=True, **kwargs) + self.setProperty("skip_settings", True) self._dark_mode_enabled = False self.layout = QHBoxLayout(self) @@ -85,7 +86,7 @@ def toggle_dark_mode(self) -> None: """ self.dark_mode_enabled = not self.dark_mode_enabled self.update_mode_button() - set_theme("dark" if self.dark_mode_enabled else "light") + apply_theme("dark" if self.dark_mode_enabled else "light") def update_mode_button(self): icon = material_icon( @@ -100,7 +101,7 @@ def update_mode_button(self): if __name__ == "__main__": app = QApplication([]) - set_theme("auto") + apply_theme("dark") w = DarkModeButton() w.show() diff --git a/docs/user/widgets/image/image_widget.md b/docs/user/widgets/image/image_widget.md index c5dc3b243..cc02dbd6d 100644 --- a/docs/user/widgets/image/image_widget.md +++ b/docs/user/widgets/image/image_widget.md @@ -32,7 +32,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='eiger', monitor_type='2d') +img_widget.image(device_name='eiger', device_entry='preview') img_widget.title = "Camera Image - Eiger Detector" ``` @@ -46,7 +46,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='waveform', monitor_type='1d') +img_widget.image(device_name='waveform', device_entry='data') img_widget.title = "Line Detector Data" # Optional: Set the color map and value range @@ -84,7 +84,7 @@ The Image Widget can be configured for different detectors by specifying the cor ```python # For a 2D camera detector -img_widget = fig.image(monitor='eiger', monitor_type='2d') +img_widget = fig.image(device_name='eiger', device_entry='preview') img_widget.set_title("Eiger Camera Image") ``` @@ -92,7 +92,7 @@ img_widget.set_title("Eiger Camera Image") ```python # For a 1D line detector -img_widget = fig.image(monitor='waveform', monitor_type='1d') +img_widget = fig.image(device_name='waveform', device_entry='data') img_widget.set_title("Line Detector Data") ``` diff --git a/docs/user/widgets/progress_bar/ring_progress_bar.md b/docs/user/widgets/progress_bar/ring_progress_bar.md index b340ba0c0..1617e66a1 100644 --- a/docs/user/widgets/progress_bar/ring_progress_bar.md +++ b/docs/user/widgets/progress_bar/ring_progress_bar.md @@ -24,11 +24,13 @@ In this example, we demonstrate how to add a `RingProgressBar` widget to a `BECD ```python # Add a new dock with a RingProgressBar widget -dock_area = gui.new('my_new_dock_area') # Create a new dock area -progress = dock_area.new().new(gui.available_widgets.RingProgressBar) +dock_area = gui.new() # Create a new dock area +progress = dock_area.new(gui.available_widgets.RingProgressBar) -# Customize the size of the progress ring -progress.set_line_widths(20) +# Add a ring to the RingProgressBar +progress.add_ring() +ring = progress.rings[0] +ring.set_value(50) # Set the progress value to 50 ``` ## Example 2 - Adding Multiple Rings to Track Parallel Tasks @@ -40,8 +42,7 @@ By default, the `RingProgressBar` widget displays a single ring. You can add add progress.add_ring() # Customize the rings -progress.rings[0].set_line_widths(20) # Set the width of the first ring -progress.rings[1].set_line_widths(10) # Set the width of the second ring +progress.rings[1].set_value(30) # Set the second ring to 30 ``` ## Example 3 - Integrating with Device Readback and Scans @@ -56,44 +57,6 @@ progress.rings[0].set_update("scan") progress.rings[1].set_update("device", "samx") ``` -## Example 4 - Customizing Visual Elements of the Rings - -The `RingProgressBar` widget offers various customization options, such as changing colors, line widths, and the gap between rings. - -```python -# Set the color of the first ring to blue -progress.rings[0].set_color("blue") - -# Set the background color of the second ring -progress.rings[1].set_background("gray") - -# Adjust the gap between the rings -progress.set_gap(5) - -# Set the diameter of the progress bar -progress.set_diameter(150) -``` - -## Example 5 - Manual Updates and Precision Control - -While the `RingProgressBar` supports automatic updates, you can also manually control the progress and set the precision for each ring. - -```python -# Disable automatic updates and manually set the progress value -progress.enable_auto_updates(False) -progress.rings[0].set_value(75) # Set the first ring to 75% - -# Set precision for the progress display -progress.set_precision(2) # Display progress with two decimal places - - -# Setting multiple rigns with different values -progress.set_number_of_bars(3) - -# Set the values of the rings to 50, 75, and 25 from outer to inner ring -progress.set_value([50, 75, 25]) -``` - ```` ````{tab} API diff --git a/pyproject.toml b/pyproject.toml index 03fcaa33b..a55d4824a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,18 +13,25 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "bec_ipython_client~=3.70", # needed for jupyter console + "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "bec_qthemes~=0.7, >=0.7", - "black~=25.0", # needed for bw-generate-cli - "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "bec_qthemes~=1.0, >=1.1.2", + "black~=25.0", # needed for bw-generate-cli + "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "ophyd_devices~=1.29, >=1.29.1", "pydantic~=2.0", "pyqtgraph==0.13.7", "PySide6==6.9.0", - "qtconsole~=5.5, >=5.5.1", # needed for jupyter console + "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", - "qtmonaco~=0.5", "thefuzz~=0.22", + "qtmonaco~=0.8, >=0.8.1", + "darkdetect~=0.8", + "PySide6-QtAds==4.4.0", + "pylsp-bec~=1.2", + "copier~=9.7", + "typer~=0.15", + "markdown~=3.9", ] @@ -41,7 +48,6 @@ dev = [ "pytest-cov~=6.1.1", "watchdog~=6.0", "pre_commit~=4.2", - ] [project.urls] @@ -52,7 +58,7 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets" bw-generate-cli = "bec_widgets.cli.generate_cli:main" bec-gui-server = "bec_widgets.cli.server:main" bec-designer = "bec_widgets.utils.bec_designer:main" -bec-app = "bec_widgets.applications.bec_app:main" +bec-app = "bec_widgets.applications.main_app:main" [tool.hatch.build.targets.wheel] include = ["*"] diff --git a/tests/end-2-end/conftest.py b/tests/end-2-end/conftest.py index 7b35c984e..dc4a123d1 100644 --- a/tests/end-2-end/conftest.py +++ b/tests/end-2-end/conftest.py @@ -47,6 +47,10 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib): try: gui.start(wait=True) qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000) + gui.bec.delete_all() # ensure clean state + qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) yield gui finally: + gui.bec.delete_all() # ensure clean state + qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) gui.kill_server() diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index a6d00d731..55c45a8dd 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -19,56 +19,33 @@ def check_dock_area_registered(): qtbot.waitUntil(check_dock_area_registered, timeout=5000) assert hasattr(gui, "cool_dock_area") - dock = dock_area.new("dock_0") + widget = dock_area.new("Waveform", object_name="cool_waveform") - def check_dock_registered(): - return dock._gui_id in gui._server_registry + def check_widget_registered(): + return widget._gui_id in gui._server_registry - qtbot.waitUntil(check_dock_registered, timeout=5000) - assert hasattr(gui.cool_dock_area, "dock_0") + qtbot.waitUntil(check_widget_registered, timeout=5000) + assert hasattr(gui.cool_dock_area, widget.object_name) def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj # BEC client shortcuts - dock = gui.bec - client = bec_client_lib - dev = client.device_manager.devices - scans = client.scans - queue = client.queue - - # Create 3 docks - d0 = dock.new("dock_0") - d1 = dock.new("dock_1") - d2 = dock.new("dock_2") - - # Check that callback for dock_registry is done - def check_docks_registered(): - return all( - [gui_id in gui._server_registry for gui_id in [d0._gui_id, d1._gui_id, d2._gui_id]] - ) - - # Waii until docks are registered - qtbot.waitUntil(check_docks_registered, timeout=5000) - assert len(dock.panels) == 3 - assert hasattr(gui.bec, "dock_0") + dock_area = gui.bec # Add 3 figures with some widgets - wf = d0.new("Waveform") - im = d1.new("Image") - mm = d2.new("MotorMap") + wf = dock_area.new("Waveform") + im = dock_area.new("Image") + mm = dock_area.new("MotorMap") - def check_figs_registered(): + def check_widgets_registered(): return all( - [gui_id in gui._server_registry for gui_id in [wf._gui_id, im._gui_id, mm._gui_id]] + gui_id in gui._server_registry for gui_id in [wf._gui_id, im._gui_id, mm._gui_id] ) - qtbot.waitUntil(check_figs_registered, timeout=5000) - - assert len(d0.element_list) == 1 - assert len(d1.element_list) == 1 - assert len(d2.element_list) == 1 + qtbot.waitUntil(check_widgets_registered, timeout=5000) + assert len(dock_area.widget_list()) == 3 assert wf.__class__.__name__ == "RPCReference" assert wf.__class__ == RPCReference @@ -82,7 +59,7 @@ def check_figs_registered(): mm.map("samx", "samy") curve = wf.plot(x_name="samx", y_name="bpm4i") - im_item = im.image("eiger") + im_item = im.image(device_name="eiger", device_entry="preview") assert curve.__class__.__name__ == "RPCReference" assert curve.__class__ == RPCReference @@ -94,48 +71,46 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj dock_area = gui.bec - d0 = dock_area.new("dock_0") - d1 = dock_area.new("dock_1") - d2 = dock_area.new("dock_2") + w0 = dock_area.new("Waveform") + w1 = dock_area.new("Waveform") + w2 = dock_area.new("Waveform") - assert hasattr(gui.bec, "dock_0") - assert hasattr(gui.bec, "dock_1") - assert hasattr(gui.bec, "dock_2") - assert len(gui.bec.panels) == 3 + assert hasattr(gui.bec, "Waveform") + assert hasattr(gui.bec, "Waveform_0") + assert hasattr(gui.bec, "Waveform_1") + assert len(gui.bec.widget_list()) == 3 - d0.detach() - dock_area.detach_dock("dock_2") - # How can we properly check that the dock is detached? - assert len(gui.bec.panels) == 3 + w0.detach() + w2.detach() + assert len(gui.bec.widget_list()) == 3 - d0.attach() - assert len(gui.bec.panels) == 3 + w0.attach() + w2.attach() + assert len(gui.bec.widget_list()) == 3 - gui_id = d2._gui_id + gui_id = w2._gui_id def wait_for_dock_removed(): return gui_id not in gui._ipython_registry - d2.remove() + w2.remove() qtbot.waitUntil(wait_for_dock_removed, timeout=5000) - assert len(gui.bec.panels) == 2 + assert len(gui.bec.widget_list()) == 2 - ids = [widget._gui_id for widget in dock_area.panel_list] + dock_area.delete_all() - def wait_for_docks_removed(): - return all(widget_id not in gui._ipython_registry for widget_id in ids) + def wait_for_all_docks_deleted(): + return len(gui.bec.widget_list()) == 0 - dock_area.delete_all() - qtbot.waitUntil(wait_for_docks_removed, timeout=5000) - assert len(gui.bec.panels) == 0 + qtbot.waitUntil(wait_for_all_docks_deleted, timeout=5000) + assert len(gui.bec.widget_list()) == 0 def test_ring_bar(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj dock_area = gui.bec - d0 = dock_area.new("dock_0") - bar = d0.new("RingProgressBar") + bar = dock_area.new("RingProgressBar") assert bar.__class__.__name__ == "RPCReference" assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar" @@ -150,11 +125,13 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot): assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "BECDockArea" xw = gui.new("X") + xw.delete_all() assert xw.__class__.__name__ == "RPCReference" assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea" assert len(gui.windows) == 2 assert gui._gui_is_alive() + qtbot.wait(500) gui.kill_server() assert not gui._gui_is_alive() gui.start(wait=True) @@ -173,17 +150,7 @@ def wait_for_gui_started(): # communication should work, main dock area should have same id and be visible yw = gui.new("Y") + yw.delete_all() assert len(gui.windows) == 2 yw.remove() assert len(gui.windows) == 1 # only bec is left - - -def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot): - gui = connected_client_gui_obj - - gui.bec.new("test") - qtbot.waitUntil(lambda: len(gui.bec.panels) == 1) # test - qtbot.wait(500) - with pytest.raises(ValueError): - gui.bec.new("test") - # time.sleep(0.1) diff --git a/tests/end-2-end/test_bec_gui_ipython.py b/tests/end-2-end/test_bec_gui_ipython.py index 8ec352825..84493af75 100644 --- a/tests/end-2-end/test_bec_gui_ipython.py +++ b/tests/end-2-end/test_bec_gui_ipython.py @@ -22,4 +22,4 @@ def test_ipython_tab_completion(bec_ipython_shell): _, completer = bec_ipython_shell assert "gui.bec" in completer.all_completions("gui.") assert "gui.bec.new" in completer.all_completions("gui.bec.") - assert "gui.bec.panels" in completer.all_completions("gui.bec.pan") + assert "gui.bec.widget_list" in completer.all_completions("gui.bec.widget_") diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index ebed9f250..e98c9236c 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -11,9 +11,9 @@ def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") c1 = wf.plot(x=[1, 2, 3], y=[1, 2, 3]) c1.set_color("red") @@ -26,13 +26,13 @@ def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj): def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec - wf = dock.new("wf_dock").new("Waveform") - im = dock.new("im_dock").new("Image") - mm = dock.new("mm_dock").new("MotorMap") - sw = dock.new("sw_dock").new("ScatterWaveform") - mw = dock.new("mw_dock").new("MultiWaveform") + wf = dock_area.new("Waveform") + im = dock_area.new("Image") + mm = dock_area.new("MotorMap") + sw = dock_area.new("ScatterWaveform") + mw = dock_area.new("MultiWaveform") c1 = wf.plot(x_name="samx", y_name="bpm4i") # Adding custom curves, removing one and adding it again should not crash @@ -42,18 +42,14 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): c3 = wf.plot(y=[1, 2, 3], x=[1, 2, 3]) assert c3.object_name == "Curve_0" - im_item = im.image(monitor="eiger") + im.image(device_name="eiger", device_entry="preview") mm.map(x_name="samx", y_name="samy") - sw.plot(x_name="samx", y_name="samy", z_name="bpm4i") - assert sw.main_curve.object_name == "bpm4i_bpm4i" - # Create a new curve on the scatter waveform should replace the old one sw.plot(x_name="samx", y_name="samy", z_name="bpm4a") - assert sw.main_curve.object_name == "bpm4a_bpm4a" mw.plot(monitor="waveform") # Adding multiple custom curves sho # Checking if classes are correctly initialised - assert len(dock.panel_list) == 5 + assert len(dock_area.widget_list()) == 5 assert wf.__class__.__name__ == "RPCReference" assert wf.__class__ == RPCReference assert gui._ipython_registry[wf._gui_id].__class__ == Waveform @@ -84,14 +80,14 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans queue = client.queue - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") # add 3 different curves to track wf.plot(x_name="samx", y_name="bpm4i") @@ -125,19 +121,18 @@ def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): @pytest.mark.timeout(100) def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans - queue = client.queue # Test add dev.waveform.sim.select_model("GaussianModel") dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300} dev.waveform.async_update.set("add").wait() dev.waveform.waveform_shape.set(10000).wait() - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") curve = wf.plot(y_name="waveform") status = scans.line_scan(dev.samx, -5, 5, steps=5, exp_time=0.05, relative=False) @@ -163,22 +158,21 @@ def _wait_for_scan_in_history(): def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans - queue = client.queue - im = dock.new("im_dock").new("Image") - im.image(monitor="eiger") + im = dock_area.new("Image") + im.image(device_name="eiger", device_entry="preview") status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False) status.wait() - last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[ - "data" - ].data + last_image_device = client.connector.get_last( + MessageEndpoints.device_preview("eiger", "preview") + )["data"].data last_image_plot = im.main_image.get_data() # check plotted data @@ -191,8 +185,9 @@ def test_rpc_motor_map(qtbot, bec_client_lib, connected_client_gui_obj): dev = client.device_manager.devices scans = client.scans - dock = gui.bec - motor_map = dock.new("mm_dock").new("MotorMap") + dock_area = gui.bec + + motor_map = dock_area.new("MotorMap") motor_map.map(x_name="samx", y_name="samy") initial_pos_x = dev.samx.read()["samx"]["value"] @@ -221,8 +216,9 @@ def test_dap_rpc(qtbot, bec_client_lib, connected_client_gui_obj): dev = client.device_manager.devices scans = client.scans - dock = gui.bec - wf = dock.new("wf_dock").new("Waveform") + dock_area = gui.bec + + wf = dock_area.new("Waveform") wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel") dev.bpm4i.sim.select_model("GaussianModel") @@ -262,8 +258,9 @@ def test_waveform_passing_device(qtbot, bec_client_lib, connected_client_gui_obj dev = client.device_manager.devices scans = client.scans - dock = gui.bec - wf = dock.new("wf_dock").new("Waveform") + dock_area = gui.bec + + wf = dock_area.new("Waveform") c1 = wf.plot( y_name=dev.samx, y_entry=dev.samx.setpoint ) # using setpoint to not use readback signal @@ -303,13 +300,13 @@ def test_rpc_waveform_history_curve( Note: Parameterization prevents adding the same logical curve twice (which would collide on label). """ gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans queue = client.queue - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") # Collect references for validation scan_meta = [] # list of dicts with scan_id, scan_number, data diff --git a/tests/end-2-end/test_rpc_register_e2e.py b/tests/end-2-end/test_rpc_register_e2e.py index 2f8d8f371..3e1bfca3d 100644 --- a/tests/end-2-end/test_rpc_register_e2e.py +++ b/tests/end-2-end/test_rpc_register_e2e.py @@ -9,21 +9,22 @@ def test_rpc_reference_objects(connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.window_list[0].new() - plt = dock.new(name="fig", widget="Waveform") + dock_area = gui.window_list[0] + plt = dock_area.new("Waveform", object_name="fig") plt.plot(x_name="samx", y_name="bpm4i") - im = dock.new("Image") - im.image("eiger") - motor_map = dock.new("MotorMap") + im = dock_area.new("Image") + im.image(device_name="eiger", device_entry="preview") + motor_map = dock_area.new("MotorMap") motor_map.map("samx", "samy") - plt_z = dock.new("Waveform") + plt_z = dock_area.new("Waveform") plt_z.plot(x_name="samx", y_name="samy", z_name="bpm4i") assert len(plt_z.curves) == 1 assert len(plt.curves) == 1 - assert im.monitor == "eiger" + assert im.device_name == "eiger" + assert im.device_entry == "preview" assert isinstance(im.main_image, RPCReference) image_item = gui._ipython_registry.get(im.main_image._gui_id, None) diff --git a/tests/end-2-end/test_rpc_widgets_e2e.py b/tests/end-2-end/test_rpc_widgets_e2e.py index b513068da..2a81168f9 100644 --- a/tests/end-2-end/test_rpc_widgets_e2e.py +++ b/tests/end-2-end/test_rpc_widgets_e2e.py @@ -1,5 +1,3 @@ -from typing import TYPE_CHECKING - import pytest from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference @@ -64,13 +62,11 @@ def check_reference_registered(): def create_widget( qtbot, gui: RPCBase, dock_area: RPCReference, widget_cls_name: str -) -> tuple[RPCReference, RPCReference, RPCReference]: +) -> RPCReference: """Utility method to create a widget and wait for the namespaces to be created.""" - dock = dock_area.new(widget=widget_cls_name) - wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id) - widget = dock.element_list[-1] - wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id) - return dock, widget + widget = dock_area.new(widget_cls_name) + wait_for_namespace_change(qtbot, gui, dock_area, widget.object_name, widget._gui_id) + return widget @pytest.mark.timeout(100) @@ -106,15 +102,12 @@ def test_available_widgets(qtbot, connected_client_gui_obj): ############################# # Create widget the widget and wait for the widget to be registered in the ipython registry - dock, widget = create_widget( - qtbot, gui, dock_area, getattr(gui.available_widgets, object_name) - ) + widget = create_widget(qtbot, gui, dock_area, getattr(gui.available_widgets, object_name)) # Check that the widget is indeed registered on the server and the client assert gui._ipython_registry.get(widget._gui_id, None) is not None assert gui._server_registry.get(widget._gui_id, None) is not None # Check that namespace was updated - assert hasattr(dock_area, dock.object_name) - assert hasattr(dock, widget.object_name) + assert hasattr(dock_area, widget.object_name) # Check that no additional top level widgets were created without a parent_id widgets = [ @@ -127,37 +120,38 @@ def test_available_widgets(qtbot, connected_client_gui_obj): ############################# ####### Remove widget ####### ############################# - - # Now we remove the widget again - dock_name = dock.object_name - dock_id = dock._gui_id - widget_id = widget._gui_id - dock_area.delete(dock.object_name) - # Wait for namespace to change - wait_for_namespace_change(qtbot, gui, dock_area, dock_name, dock_id, exists=False) - # Assert that dock and widget are removed from the ipython registry and the namespace - assert hasattr(dock_area, dock_name) is False - # Client registry - assert gui._ipython_registry.get(dock_id, None) is None - assert gui._ipython_registry.get(widget_id, None) is None - # Server registry - assert gui._server_registry.get(dock_id, None) is None - assert gui._server_registry.get(widget_id, None) is None - - # Check that the number of top level widgets is still the same. As the cleanup is done by the - # qt event loop, we need to wait for the qtbot to finish the cleanup try: - qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count) - except Exception as exc: - raise RuntimeError( - f"Widget {object_name} was not removed properly. The number of top level widgets " - f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following " - f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}" - ) from exc - # Number of widgets with parent_id == None, should be 2 - widgets = [ - widget - for widget in gui._server_registry.values() - if widget["config"]["parent_id"] is None - ] - assert len(widgets) == 2 + # Now we remove the widget again + widget_id = widget._gui_id + widget.remove() + # Wait for namespace to change + wait_for_namespace_change( + qtbot, gui, dock_area, widget.object_name, widget_id, exists=False + ) + # Assert that widget is removed from the ipython registry and the namespace + assert hasattr(dock_area, widget.object_name) is False + # Client registry + assert gui._ipython_registry.get(widget_id, None) is None + # Server registry + assert gui._server_registry.get(widget_id, None) is None + + # Check that the number of top level widgets is still the same. As the cleanup is done by the + # qt event loop, we need to wait for the qtbot to finish the cleanup + try: + qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count) + except Exception as exc: + raise RuntimeError( + f"Widget {object_name} was not removed properly. The number of top level widgets " + f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following " + f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}" + ) from exc + # Number of widgets with parent_id == None, should be 2 + widgets = [ + widget + for widget in gui._server_registry.values() + if widget["config"]["parent_id"] is None + ] + assert len(widgets) == 2 + + except Exception as e: + raise RuntimeError(f"Failed to remove widget {object_name}") from e diff --git a/tests/end-2-end/user_interaction/conftest.py b/tests/end-2-end/user_interaction/conftest.py index f34e7f66c..f7de1d080 100644 --- a/tests/end-2-end/user_interaction/conftest.py +++ b/tests/end-2-end/user_interaction/conftest.py @@ -77,6 +77,10 @@ def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib): try: gui.start(wait=True) qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000) + gui.bec.delete_all() # ensure clean state + qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) yield gui finally: + gui.bec.delete_all() # ensure clean state + qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) gui.kill_server() diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 98fb26c8b..cb5d85b57 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -16,6 +16,7 @@ import numpy as np import pytest +from bec_lib.endpoints import MessageEndpoints from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference @@ -98,20 +99,16 @@ def check_reference_registered(): ) from e -def create_widget( - qtbot, gui: BECGuiClient, widget_cls_name: str -) -> tuple[RPCReference, RPCReference]: +def create_widget(qtbot, gui: BECGuiClient, widget_cls_name: str) -> RPCReference: """Utility method to create a widget and wait for the namespaces to be created.""" if hasattr(gui, "dock_area"): - dock_area: client.BECDockArea = gui.dock_area + dock_area = gui.dock_area else: - dock_area: client.BECDockArea = gui.new(name="dock_area") + dock_area = gui.new(name="dock_area") wait_for_namespace_change(qtbot, gui, gui, dock_area.object_name, dock_area._gui_id) - dock: client.BECDock = dock_area.new() - wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id) - widget = dock.new(widget=widget_cls_name) - wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id) - return dock, widget + widget = dock_area.new(widget=widget_cls_name) + wait_for_namespace_change(qtbot, gui, dock_area, widget.object_name, widget._gui_id) + return widget @pytest.fixture(scope="module") @@ -133,39 +130,20 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand # Needed, reference gets deleted in the gui name = gui.dock_area.object_name gui_id = gui.dock_area._gui_id + gui.dock_area.delete_all() # start fresh gui.delete("dock_area") wait_for_namespace_change( qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False ) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the AbortButton widget.""" - gui: BECGuiClient = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton) - dock: client.BECDock - widget: client.AbortButton - - # No rpc calls to check so far - - # Try detaching the dock - dock.detach() - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the BECProgressBar widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar) widget: client.BECProgressBar # Check rpc calls @@ -185,9 +163,8 @@ def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator """Test the BECQueue widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue) widget: client.BECQueue # No rpc calls to test so far @@ -202,8 +179,8 @@ def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_gene """Test the BECStatusBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox) + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox) # Check rpc calls assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"] @@ -217,9 +194,8 @@ def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_gener """Test the DAPComboBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox) widget: client.DAPComboBox # Check rpc calls @@ -236,9 +212,8 @@ def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_gene """Test the DeviceBrowser widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser) widget: client.DeviceBrowser # No rpc calls yet to check @@ -247,112 +222,19 @@ def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_gene maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceComboBox widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox) - dock: client.BECDock - widget: client.DeviceComboBox - - assert "samx" in widget.devices - assert "bpm4i" in widget.devices - - widget.set_device("samx") - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceLineEdit widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit) - dock: client.BECDock - widget: client.DeviceLineEdit - - assert widget._is_valid_input is False - assert "samx" in widget.devices - assert "bpm4i" in widget.devices - - widget.set_device("samx") - assert widget._is_valid_input is True - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_signal_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceSignalLineEdit widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit) - dock: client.BECDock - widget: client.SignalLineEdit - - widget.set_device("samx") - assert widget._is_valid_input is False - assert widget.signals == [ - "readback", - "setpoint", - "motor_is_moving", - "velocity", - "acceleration", - "tolerance", - ] - widget.set_signal("readback") - assert widget._is_valid_input is True - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceSignalComboBox widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - _, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) - widget: client.SignalComboBox - - widget.set_device("samx") - info = bec.device_manager.devices.samx._info["signals"] - assert widget.signals == [ - ["samx (readback)", info.get("readback")], - ["setpoint", info.get("setpoint")], - ["motor_is_moving", info.get("motor_is_moving")], - ["velocity", info.get("velocity")], - ["acceleration", info.get("acceleration")], - ["tolerance", info.get("tolerance")], - ] - widget.set_signal("samx (readback)") - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the Image widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Image) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Image) widget: client.Image scans = bec.scans dev = bec.device_manager.devices # Test rpc calls - img = widget.image(dev.eiger) + img = widget.image(device_name=dev.eiger.name, device_entry="preview") assert img.get_data() is None # Run a scan and plot the image s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) @@ -366,13 +248,13 @@ def _wait_for_scan_in_history(): qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000) # Check that last image is equivalent to data in Redis - last_img = bec.device_monitor.get_data( - dev.eiger, count=1 - ) # Get last image from Redis monitor 2D endpoint + last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[ + "data" + ].data assert np.allclose(img.get_data(), last_img) # Now add a device with a preview signal - img = widget.image(["eiger", "preview"]) + img = widget.image(device_name="eiger", device_entry="preview") s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) s.wait() @@ -388,9 +270,8 @@ def _wait_for_scan_in_history(): # """Test the LogPanel widget.""" # gui = connected_client_gui_obj # bec = gui._client -# # Create dock_area, dock, widget -# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel) -# dock: client.BECDock +# # Create dock_area and widget +# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel) # widget: client.LogPanel # # No rpc calls to check so far @@ -404,9 +285,8 @@ def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generat """Test the MineSweeper widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper) widget: client.MineSweeper # No rpc calls to check so far @@ -420,9 +300,8 @@ def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator """Test the MotorMap widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap) widget: client.MotorMap # Test RPC calls @@ -450,9 +329,8 @@ def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_gene """Test MultiWaveform widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform) widget: client.MultiWaveform # Test RPC calls @@ -489,9 +367,8 @@ def test_widgets_e2e_positioner_indicator( """Test the PositionIndicator widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator) widget: client.PositionIndicator # TODO check what these rpc calls are supposed to do! Issue created #461 @@ -506,9 +383,8 @@ def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_gene """Test the PositionerBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox) widget: client.PositionerBox # Test rpc calls @@ -529,9 +405,8 @@ def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_g """Test the PositionerBox2D widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D) widget: client.PositionerBox2D # Test rpc calls @@ -556,9 +431,8 @@ def test_widgets_e2e_positioner_control_line( """Test the positioner control line widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine) widget: client.PositionerControlLine # Test rpc calls @@ -574,17 +448,19 @@ def test_widgets_e2e_positioner_control_line( maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) +# TODO passes locally, fails on CI for some reason... -> issue #1003 @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the RingProgressBar widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) widget: client.RingProgressBar - widget.set_number_of_bars(3) + widget.add_ring() + widget.add_ring() + widget.add_ring() widget.rings[0].set_update("manual") widget.rings[0].set_value(30) widget.rings[0].set_min_max_values(0, 100) @@ -606,9 +482,8 @@ def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_genera """Test the ScanControl widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl) widget: client.ScanControl # No rpc calls to check so far @@ -622,9 +497,8 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge """Test the ScatterWaveform widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform) widget: client.ScatterWaveform # Test rpc calls @@ -637,61 +511,13 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton) - dock: client.BECDock - widget: client.StopButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton) - dock: client.BECDock - widget: client.ResumeButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton) - dock: client.BECDock - widget: client.ResetButton - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the TextBox widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.TextBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.TextBox) widget: client.TextBox # RPC calls @@ -707,9 +533,8 @@ def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_ """Test the Waveform widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Waveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Waveform) widget: client.Waveform # Test rpc calls diff --git a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png index 2b75d66a8..54bd8c5e3 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png and b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_linux.png index 2b75d66a8..462241215 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_linux.png and b/tests/references/SpinnerWidget/SpinnerWidget_linux.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png index ff6827cd8..85c5a2441 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png and b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png index bf2d9470f..662bd4f75 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png and b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png differ diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index db5427dc3..6f81a4cf8 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -6,13 +6,19 @@ import pytest from bec_lib import messages from bec_lib.messages import _StoredDataInfo +from bec_qthemes import apply_theme from pytestqt.exceptions import TimeoutError as QtBotTimeoutError +from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtWidgets import QApplication, QMessageBox from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module from bec_widgets.utils import error_popups +# Patch to set default RAISE_ERROR_DEFAULT to True for tests +# This means that by default, error popups will raise exceptions during tests +# error_popups.RAISE_ERROR_DEFAULT = True + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): @@ -23,12 +29,26 @@ def pytest_runtest_makereport(item, call): item.stash["failed"] = rep.failed +def process_all_deferred_deletes(qapp): + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents(QEventLoop.AllEvents) + + @pytest.fixture(autouse=True) def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument + qapp = QApplication.instance() + process_all_deferred_deletes(qapp) + apply_theme("light") + qapp.processEvents() + yield # if the test failed, we don't want to check for open widgets as # it simply pollutes the output + # stop pyepics dispatcher for leaking tests + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() if request.node.stash._storage.get("failed"): print("Test failed, skipping cleanup checks") return @@ -36,7 +56,6 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus bec_dispatcher.stop_cli_server() testable_qtimer_class.check_all_stopped(qtbot) - qapp = QApplication.instance() qapp.processEvents() if hasattr(qapp, "os_listener") and qapp.os_listener: qapp.removeEventFilter(qapp.os_listener) diff --git a/tests/unit_tests/test_abort_button.py b/tests/unit_tests/test_abort_button.py index ca6c18a29..d744bd048 100644 --- a/tests/unit_tests/test_abort_button.py +++ b/tests/unit_tests/test_abort_button.py @@ -17,10 +17,6 @@ def abort_button(qtbot, mocked_client): def test_abort_button(abort_button): assert abort_button.button.text() == "Abort" - assert ( - abort_button.button.styleSheet() - == "background-color: #666666; color: white; font-weight: bold; font-size: 12px;" - ) abort_button.button.click() assert abort_button.queue.request_scan_abortion.called abort_button.close() diff --git a/tests/unit_tests/test_app_side_bar.py b/tests/unit_tests/test_app_side_bar.py new file mode 100644 index 000000000..830844ac7 --- /dev/null +++ b/tests/unit_tests/test_app_side_bar.py @@ -0,0 +1,189 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup, QSize + +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import ( + NavigationItem, + SectionHeader, +) + +ANIM_TEST_DURATION = 60 # ms + + +def _run(group: QParallelAnimationGroup, qtbot, duration=ANIM_TEST_DURATION): + group.start() + qtbot.wait(duration + 100) + + +@pytest.fixture +def header(qtbot): + w = SectionHeader(text="Group", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_section_header_initial_state_collapsed(header): + # RevealAnimator is initially collapsed for the label + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +def test_section_header_animates_reveal_and_hide(header, qtbot): + group = QParallelAnimationGroup() + for anim in header.build_animations(): + group.addAnimation(anim) + + # Expand + header.setup_animations(True) + _run(group, qtbot) + sh = header.lbl.sizeHint() + assert header.lbl.maximumWidth() >= sh.width() + assert header.lbl.maximumHeight() >= sh.height() + + # Collapse + header.setup_animations(False) + _run(group, qtbot) + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +@pytest.fixture +def nav(qtbot): + w = NavigationItem( + title="Counter", icon_name="widgets", mini_text="cnt", anim_duration=ANIM_TEST_DURATION + ) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_build_animations_contains(nav): + lst = nav.build_animations() + assert len(lst) == 5 + + +def test_setup_animations_changes_targets(nav, qtbot): + group = QParallelAnimationGroup() + for a in nav.build_animations(): + group.addAnimation(a) + + # collapsed -> expanded + nav.setup_animations(True) + _run(group, qtbot) + + sh_title = nav.title_lbl.sizeHint() + assert nav.title_lbl.maximumWidth() >= sh_title.width() + assert nav.mini_lbl.maximumHeight() == 0 + assert nav.icon_btn.iconSize() == QSize(26, 26) + + # expanded -> collapsed + nav.setup_animations(False) + _run(group, qtbot) + assert nav.title_lbl.maximumWidth() == 0 + sh_mini = nav.mini_lbl.sizeHint() + assert nav.mini_lbl.maximumHeight() >= sh_mini.height() + assert nav.icon_btn.iconSize() == QSize(20, 20) + + +def test_activation_signal_emits(nav, qtbot): + with qtbot.waitSignal(nav.activated, timeout=1000): + nav.icon_btn.click() + + +@pytest.fixture +def sidebar(qtbot): + sb = SideBar(title="Controls", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(sb) + qtbot.waitExposed(sb) + return sb + + +def test_add_section_and_separator(sidebar): + sec = sidebar.add_section("Group A", id="group_a") + assert sec is not None + sep = sidebar.add_separator() + assert sep is not None + assert sidebar.content_layout.indexOf(sep) != -1 + + +def test_add_item_top_and_bottom_positions(sidebar): + top_item = sidebar.add_item(icon="widgets", title="Top", id="top") + bottom_item = sidebar.add_item(icon="widgets", title="Bottom", id="bottom", from_top=False) + + i_spacer = sidebar.content_layout.indexOf(sidebar._bottom_spacer) + i_top = sidebar.content_layout.indexOf(top_item) + i_bottom = sidebar.content_layout.indexOf(bottom_item) + + assert i_top != -1 and i_bottom != -1 + assert i_bottom > i_spacer # bottom items go after the spacer + + +def test_selection_exclusive_and_nonexclusive(sidebar, qtbot): + a = sidebar.add_item(icon="widgets", title="A", id="a", exclusive=True) + b = sidebar.add_item(icon="widgets", title="B", id="b", exclusive=True) + c = sidebar.add_item(icon="widgets", title="C", id="c", exclusive=False) + + c._emit_activated() + qtbot.wait(10) + assert c.is_active() is True + + a._emit_activated() + qtbot.wait(10) + assert a.is_active() is True + assert b.is_active() is False + assert c.is_active() is True + + b._emit_activated() + qtbot.wait(200) + assert a.is_active() is False + assert b.is_active() is True + assert c.is_active() is True + + +def test_on_expand_configures_targets_and_shows_title(sidebar, qtbot): + # Start collapsed + assert sidebar._is_expanded is False + start_w = sidebar.width() + + sidebar.on_expand() + + assert sidebar.width_anim.startValue() == start_w + assert sidebar.width_anim.endValue() == sidebar._expanded_width + assert sidebar.title_anim.endValue() == 1.0 + + +def test__on_anim_finished_hides_on_collapse_and_resets_alignment(sidebar, qtbot): + # Add one item so set_visible is called on components too + item = sidebar.add_item(icon="widgets", title="Item", id="item") + + # Expand first + sidebar.on_expand() + qtbot.wait(ANIM_TEST_DURATION + 150) + assert sidebar._is_expanded is True + + # Now collapse + sidebar.on_expand() + # Wait for animation group to finish and _on_anim_finished to run + with qtbot.waitSignal(sidebar.group.finished, timeout=2000): + pass + + # Collapsed state + assert sidebar._is_expanded is False + + +def test_dark_mode_item_is_action(sidebar, qtbot, monkeypatch): + dm = sidebar.add_dark_mode_item() + + called = {"toggled": False} + + def fake_apply(theme): + called["toggled"] = True + + monkeypatch.setattr("bec_widgets.utils.colors.apply_theme", fake_apply, raising=False) + + before = dm.is_active() + dm._emit_activated() + qtbot.wait(200) + assert called["toggled"] is True + assert dm.is_active() == before diff --git a/tests/unit_tests/test_bec_connector.py b/tests/unit_tests/test_bec_connector.py index 62b88ca76..55773232b 100644 --- a/tests/unit_tests/test_bec_connector.py +++ b/tests/unit_tests/test_bec_connector.py @@ -3,9 +3,10 @@ import pytest from qtpy.QtCore import QObject -from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QApplication, QWidget from bec_widgets.utils import BECConnector +from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.error_popups import SafeSlot as Slot from .client_mocks import mocked_client @@ -38,6 +39,18 @@ def test_bec_connector_set_gui_id(bec_connector): assert bec_connector.config.gui_id == "test_gui_id" +def test_bec_connector_sanitize_names(mocked_client): + class MyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + + widget = MyWidget(client=mocked_client) + widget.setObjectName("Test Name With Spaces") + assert widget.objectName() == "Test_Name_With_Spaces" + widget.setObjectName("Test@Name#With$Special%Characters!") + assert widget.objectName() == "Test_Name_With_Special_Characters_" + + def test_bec_connector_change_config(bec_connector): bec_connector.on_config_update({"gui_id": "test_gui_id"}) assert bec_connector.config.gui_id == "test_gui_id" @@ -131,3 +144,33 @@ def test_bec_connector_change_object_name(bec_connector): # Verify that the object with the previous name is no longer registered all_objects = bec_connector.rpc_register.list_all_connections().values() assert not any(obj.objectName() == previous_name for obj in all_objects) + + +def test_bec_connector_export_settings(): + + class MyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.setWindowTitle("My Widget") + self._my_str_property = "default" + + @SafeProperty(str) + def my_str_property(self) -> str: + return self._my_str_property + + @my_str_property.setter + def my_str_property(self, value: str): + self._my_str_property = value + + @property + def my_int_property(self) -> int: + return 42 + + widget = MyWidget(client=mocked_client) + out = widget.export_settings() + assert len(out) == 1 + assert out["my_str_property"] == "default" + + config = {"my_str_property": "new_value"} + widget.load_settings(config) + assert widget.my_str_property == "new_value" diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py deleted file mode 100644 index 2f117ae3f..000000000 --- a/tests/unit_tests/test_bec_dock.py +++ /dev/null @@ -1,233 +0,0 @@ -# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import - -from unittest import mock - -import pytest -from bec_lib.endpoints import MessageEndpoints - -from bec_widgets.widgets.containers.dock import BECDockArea - -from .client_mocks import mocked_client -from .test_bec_queue import bec_queue_msg_full - - -@pytest.fixture -def bec_dock_area(qtbot, mocked_client): - widget = BECDockArea(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_bec_dock_area_init(bec_dock_area): - assert bec_dock_area is not None - assert bec_dock_area.client is not None - assert isinstance(bec_dock_area, BECDockArea) - assert bec_dock_area.config.widget_class == "BECDockArea" - - -def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot): - initial_count = len(bec_dock_area.dock_area.docks) - - # Adding 3 docks - d0 = bec_dock_area.new() - d1 = bec_dock_area.new() - d2 = bec_dock_area.new() - - # Check if the docks were added - assert len(bec_dock_area.dock_area.docks) == initial_count + 3 - assert d0.name() in dict(bec_dock_area.dock_area.docks) - assert d1.name() in dict(bec_dock_area.dock_area.docks) - assert d2.name() in dict(bec_dock_area.dock_area.docks) - assert bec_dock_area.dock_area.docks[d0.name()].config.widget_class == "BECDock" - assert bec_dock_area.dock_area.docks[d1.name()].config.widget_class == "BECDock" - assert bec_dock_area.dock_area.docks[d2.name()].config.widget_class == "BECDock" - - # Check panels API for getting docks to CLI - assert bec_dock_area.panels == dict(bec_dock_area.dock_area.docks) - - # Remove docks - d0_name = d0.name() - bec_dock_area.delete(d0_name) - d1.remove() - - qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200) - assert d0.name() not in dict(bec_dock_area.dock_area.docks) - assert d1.name() not in dict(bec_dock_area.dock_area.docks) - assert d2.name() in dict(bec_dock_area.dock_area.docks) - - -def test_close_docks(bec_dock_area, qtbot): - _ = bec_dock_area.new(name="dock_0") - _ = bec_dock_area.new(name="dock_1") - _ = bec_dock_area.new(name="dock_2") - - bec_dock_area.delete_all() - qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0) - - -def test_undock_and_dock_docks(bec_dock_area, qtbot): - d0 = bec_dock_area.new(name="dock_0") - d1 = bec_dock_area.new(name="dock_1") - d2 = bec_dock_area.new(name="dock_4") - d3 = bec_dock_area.new(name="dock_3") - - d0.detach() - bec_dock_area.detach_dock("dock_1") - d2.detach() - - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 3 - - d0.attach() - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 2 - - bec_dock_area.attach_all() - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 0 - - -def test_new_dock_raises_for_invalid_name(bec_dock_area): - with pytest.raises(ValueError): - bec_dock_area.new( - name="new", _override_slot_params={"popup_error": False, "raise_error": True} - ) - - -################################### -# Toolbar Actions -################################### -def test_toolbar_add_plot_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["waveform"].action.trigger() - assert "waveform_0" in bec_dock_area.panels - assert bec_dock_area.panels["waveform_0"].widgets[0].config.widget_class == "Waveform" - - -def test_toolbar_add_plot_scatter_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions[ - "scatter_waveform" - ].action.trigger() - assert "scatter_waveform_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["scatter_waveform_0"].widgets[0].config.widget_class - == "ScatterWaveform" - ) - - -def test_toolbar_add_plot_image(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["image"].action.trigger() - assert "image_0" in bec_dock_area.panels - assert bec_dock_area.panels["image_0"].widgets[0].config.widget_class == "Image" - - -def test_toolbar_add_plot_motor_map(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["motor_map"].action.trigger() - assert "motor_map_0" in bec_dock_area.panels - assert bec_dock_area.panels["motor_map_0"].widgets[0].config.widget_class == "MotorMap" - - -def test_toolbar_add_multi_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions[ - "multi_waveform" - ].action.trigger() - # Check if the MultiWaveform panel is created - assert "multi_waveform_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["multi_waveform_0"].widgets[0].config.widget_class == "MultiWaveform" - ) - - -def test_toolbar_add_device_positioner_box(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_devices").actions[ - "positioner_box" - ].action.trigger() - assert "positioner_box_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["positioner_box_0"].widgets[0].config.widget_class == "PositionerBox" - ) - - -def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full): - bec_dock_area.client.connector.set_and_publish( - MessageEndpoints.scan_queue_status(), bec_queue_msg_full - ) - bec_dock_area.toolbar.components.get_action("menu_utils").actions["queue"].action.trigger() - assert "bec_queue_0" in bec_dock_area.panels - assert bec_dock_area.panels["bec_queue_0"].widgets[0].config.widget_class == "BECQueue" - - -def test_toolbar_add_utils_status(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_utils").actions["status"].action.trigger() - assert "bec_status_box_0" in bec_dock_area.panels - assert bec_dock_area.panels["bec_status_box_0"].widgets[0].config.widget_class == "BECStatusBox" - - -def test_toolbar_add_utils_progress_bar(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_utils").actions[ - "progress_bar" - ].action.trigger() - assert "ring_progress_bar_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class - == "RingProgressBar" - ) - - -def test_toolbar_screenshot_action(bec_dock_area, tmpdir): - """Test the screenshot functionality from the toolbar.""" - # Create a test screenshot file path in tmpdir - screenshot_path = tmpdir.join("test_screenshot.png") - - # Mock the QFileDialog.getSaveFileName to return a test filename - with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: - mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)") - - # Mock the screenshot.save method - with mock.patch.object(bec_dock_area, "grab") as mock_grab: - mock_screenshot = mock.MagicMock() - mock_grab.return_value = mock_screenshot - - # Trigger the screenshot action - bec_dock_area.toolbar.components.get_action("screenshot").action.trigger() - - # Verify the dialog was called with correct parameters - mock_dialog.assert_called_once() - call_args = mock_dialog.call_args[0] - assert call_args[0] == bec_dock_area # parent widget - assert call_args[1] == "Save Screenshot" # dialog title - assert call_args[2].startswith("bec_") # filename starts with bec_ - assert call_args[2].endswith(".png") # filename ends with .png - assert ( - call_args[3] == "PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)" - ) # file filter - - # Verify grab was called - mock_grab.assert_called_once() - - # Verify save was called with the filename - mock_screenshot.save.assert_called_once_with(str(screenshot_path)) - - -def test_toolbar_screenshot_action_cancelled(bec_dock_area): - """Test the screenshot functionality when user cancels the dialog.""" - # Mock the QFileDialog.getSaveFileName to return empty filename (cancelled) - with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: - mock_dialog.return_value = ("", "") - - # Mock the screenshot.save method - with mock.patch.object(bec_dock_area, "grab") as mock_grab: - mock_screenshot = mock.MagicMock() - mock_grab.return_value = mock_screenshot - - # Trigger the screenshot action - bec_dock_area.toolbar.components.get_action("screenshot").action.trigger() - - # Verify the dialog was called - mock_dialog.assert_called_once() - - # Verify grab was called (screenshot is taken before dialog) - mock_grab.assert_called_once() - - # Verify save was NOT called since dialog was cancelled - mock_screenshot.save.assert_not_called() diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py new file mode 100644 index 000000000..0425c78bb --- /dev/null +++ b/tests/unit_tests/test_busy_loader.py @@ -0,0 +1,144 @@ +import numpy as np +import pytest +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +from .client_mocks import mocked_client + + +class _TestBusyWidget(BECWidget, QWidget): + def __init__( + self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs + ): + super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(QLabel("content", self)) + + +@pytest.fixture +def widget_busy(qtbot, mocked_client): + w = _TestBusyWidget(client=mocked_client, start_busy=True) + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +@pytest.fixture +def widget_idle(qtbot): + w = _TestBusyWidget(client=mocked_client, start_busy=False) + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "BECWidget should create a busy overlay in __init__" + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + qtbot.waitUntil(lambda: overlay.isVisible()) + + +def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): + overlay = getattr(widget_idle, "_busy_overlay", None) + assert overlay is not None + + widget_idle.set_busy(True) + overlay = getattr(widget_idle, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + assert hasattr(widget_idle, "_busy_state_widget") + assert overlay._custom_widget is not None + + label = overlay._custom_widget.findChild(QLabel) + assert label is not None + assert label.text() == "Loading..." + + spinner = overlay._custom_widget.findChild(SpinnerWidget) + assert spinner is not None + assert spinner.isVisible() + assert spinner._started is True + + widget_idle.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + +def test_becwidget_busy_overlay_set_opacity(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Default opacity is 0.7 + frame = getattr(overlay, "_frame", None) + assert frame is not None + sheet = frame.styleSheet() + _, _, _, a = overlay.scrim_color.getRgb() + assert np.isclose(a / 255, 0.35, atol=0.02) + + # Change opacity + overlay.set_opacity(0.7) + qtbot.waitUntil(lambda: overlay.isVisible()) + _, _, _, a = overlay.scrim_color.getRgb() + assert np.isclose(a / 255, 0.7, atol=0.02) + + +def test_becwidget_overlay_tracks_resize(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + widget_busy.resize(480, 260) + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + +def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + frame = getattr(overlay, "_frame", None) + assert frame is not None, "Busy overlay must use an internal QFrame for visuals" + + # Insets are 10 px in the implementation + outer = overlay.rect() + # Ensure resizeEvent has run and frame geometry is updated + qtbot.waitUntil( + lambda: frame.geometry().width() == outer.width() - 20 + and frame.geometry().height() == outer.height() - 20 + ) + + inner = frame.geometry() + assert inner.left() == outer.left() + 10 + assert inner.top() == outer.top() + 10 + assert inner.right() == outer.right() - 10 + assert inner.bottom() == outer.bottom() - 10 + + # Style: dashed border + semi-transparent grey background + ss = frame.styleSheet() + assert "dashed" in ss + assert "border" in ss + + +def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "Busy overlay should exist on a start_busy widget" + + # Initially visible because start_busy=True + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Switch OFF + widget_busy.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + # Switch ON again (with new text) + widget_busy.set_busy(True) + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Same overlay instance reused (no duplication) + assert getattr(widget_busy, "_busy_overlay") is overlay + + # Geometry follows parent after re-show + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) diff --git a/tests/unit_tests/test_client_plugin_widgets.py b/tests/unit_tests/test_client_plugin_widgets.py index 161fe2d83..a863c58c5 100644 --- a/tests/unit_tests/test_client_plugin_widgets.py +++ b/tests/unit_tests/test_client_plugin_widgets.py @@ -1,6 +1,5 @@ import enum import inspect -import sys from importlib import reload from types import SimpleNamespace from unittest.mock import MagicMock, call, patch @@ -35,9 +34,9 @@ class _TestDuplicatePlugin(RPCBase): ... mock_client_module_duplicate = SimpleNamespace() -_TestDuplicatePlugin.__name__ = "DeviceComboBox" +_TestDuplicatePlugin.__name__ = "Waveform" -mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin +mock_client_module_duplicate.Waveform = _TestDuplicatePlugin @patch("bec_lib.logger.bec_logger") @@ -48,15 +47,15 @@ class _TestDuplicatePlugin(RPCBase): ... @patch( "bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets", return_value=BECClassContainer( - [BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")] + [BECClassInfo(name="Waveform", obj=_TestDuplicatePlugin, module="", file="")] ), ) def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock): reload(client) assert ( call( - f"Detected duplicate widget DeviceComboBox in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !" + f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !" ) in bec_logger.logger.warning.mock_calls ) - assert client.BECDock is not _TestDuplicatePlugin + assert client.Waveform is not _TestDuplicatePlugin diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 05b7d61e5..db2589e81 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -31,13 +31,13 @@ def test_rpc_call_new_dock(cli_dock_area): ) def test_client_utils_start_plot_process(config, call_config): with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen: - _start_plot_process("gui_id", "bec", config, gui_class="BECDockArea") + _start_plot_process("gui_id", "bec", config, gui_class="AdvancedDockArea") command = [ "bec-gui-server", "--id", "gui_id", "--gui_class", - "BECDockArea", + "AdvancedDockArea", "--gui_class_id", "bec", "--hide", diff --git a/tests/unit_tests/test_collapsible_tree_section.py b/tests/unit_tests/test_collapsible_tree_section.py new file mode 100644 index 000000000..028f5fe03 --- /dev/null +++ b/tests/unit_tests/test_collapsible_tree_section.py @@ -0,0 +1,119 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +from unittest import mock + +import pytest +from qtpy.QtCore import QMimeData, QPoint, Qt +from qtpy.QtWidgets import QLabel + +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection + + +@pytest.fixture +def collapsible_section(qtbot): + """Create a basic CollapsibleSection widget for testing""" + widget = CollapsibleSection(title="Test Section") + qtbot.addWidget(widget) + yield widget + + +@pytest.fixture +def dummy_content_widget(qtbot): + """Create a simple widget to be used as content""" + widget = QLabel("Test Content") + qtbot.addWidget(widget) + return widget + + +def test_basic_initialization(collapsible_section): + """Test basic initialization""" + assert collapsible_section.title == "Test Section" + assert collapsible_section.expanded is True + assert collapsible_section.content_widget is None + + +def test_toggle_expanded(collapsible_section): + """Test toggling expansion state""" + assert collapsible_section.expanded is True + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is False + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is True + + +def test_set_widget(collapsible_section, dummy_content_widget): + """Test setting content widget""" + collapsible_section.set_widget(dummy_content_widget) + assert collapsible_section.content_widget == dummy_content_widget + assert dummy_content_widget.parent() == collapsible_section + + +def test_connect_add_button(qtbot): + """Test connecting add button""" + widget = CollapsibleSection(title="Test", show_add_button=True) + qtbot.addWidget(widget) + + mock_slot = mock.MagicMock() + widget.connect_add_button(mock_slot) + + qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton) + mock_slot.assert_called_once() + + +def test_section_reorder_signal(collapsible_section): + """Test section reorder signal emission""" + signals_received = [] + collapsible_section.section_reorder_requested.connect( + lambda source, target: signals_received.append((source, target)) + ) + + # Create mock drop event + mime_data = QMimeData() + mime_data.setText("section:Source Section") + + mock_event = mock.MagicMock() + mock_event.mimeData.return_value = mime_data + + collapsible_section._header_drop_event(mock_event) + + assert len(signals_received) == 1 + assert signals_received[0] == ("Source Section", "Test Section") + + +def test_nested_collapsible_sections(qtbot): + """Test that collapsible sections can be nested""" + # Create parent section + parent_section = CollapsibleSection(title="Parent Section") + qtbot.addWidget(parent_section) + + # Create child section + child_section = CollapsibleSection(title="Child Section") + qtbot.addWidget(child_section) + + # Add some content to the child section + child_content = QLabel("Child Content") + qtbot.addWidget(child_content) + child_section.set_widget(child_content) + + # Nest the child section inside the parent + parent_section.set_widget(child_section) + + # Verify nesting structure + assert parent_section.content_widget == child_section + assert child_section.parent() == parent_section + assert child_section.content_widget == child_content + assert child_content.parent() == child_section + + # Test that both sections can expand/collapse independently + assert parent_section.expanded is True + assert child_section.expanded is True + + # Collapse child section + child_section.toggle_expanded() + assert child_section.expanded is False + assert parent_section.expanded is True # Parent should remain expanded + + # Collapse parent section + parent_section.toggle_expanded() + assert parent_section.expanded is False + assert child_section.expanded is False # Child state unchanged diff --git a/tests/unit_tests/test_color_utils.py b/tests/unit_tests/test_color_utils.py index 7628e8ae4..39c46473e 100644 --- a/tests/unit_tests/test_color_utils.py +++ b/tests/unit_tests/test_color_utils.py @@ -82,6 +82,45 @@ def test_rgba_to_hex(): assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF" +def test_canonical_colormap_name_case_insensitive(): + available = Colors.list_available_colormaps() + presets = Colors.list_available_gradient_presets() + if not available and not presets: + pytest.skip("No colormaps or presets available to test canonical mapping.") + + name = (available or presets)[0] + requested = name.swapcase() + assert Colors.canonical_colormap_name(requested) == name + + +def test_validate_color_map_returns_canonical_name(): + available = Colors.list_available_colormaps() + presets = Colors.list_available_gradient_presets() + if not available and not presets: + pytest.skip("No colormaps or presets available to test validation.") + + name = (available or presets)[0] + requested = name.swapcase() + assert Colors.validate_color_map(requested) == name + + +def test_get_colormap_uses_gradient_preset_fallback(monkeypatch): + presets = Colors.list_available_gradient_presets() + if not presets: + pytest.skip("No gradient presets available to test fallback.") + + preset = presets[0] + Colors._get_colormap_cached.cache_clear() + + def _raise(*args, **kwargs): + raise Exception("registry unavailable") + + monkeypatch.setattr(pg.colormap, "get", _raise) + + cmap = Colors._get_colormap_cached(preset) + assert isinstance(cmap, pg.ColorMap) + + @pytest.mark.parametrize("num", [10, 100, 400]) def test_evenly_spaced_colors(num): colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor") @@ -144,6 +183,19 @@ def __init__( self.glw.addItem(self.pi) self.pi.plot([1, 2, 3, 4, 5], pen="r") + def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None): + """Cleanup pyqtgraph items.""" + if item is None: + item = self.pi + item.vb.menu.close() + item.vb.menu.deleteLater() + item.ctrlMenu.close() + item.ctrlMenu.deleteLater() + + def cleanup(self): + self.cleanup_pyqtgraph() + super().cleanup() + def test_apply_theme(qtbot, mocked_client): widget = create_widget(qtbot, ExamplePlotWidget, client=mocked_client) diff --git a/tests/unit_tests/test_config_communicator.py b/tests/unit_tests/test_config_communicator.py index 04e75bded..e4274565a 100644 --- a/tests/unit_tests/test_config_communicator.py +++ b/tests/unit_tests/test_config_communicator.py @@ -59,3 +59,13 @@ def test_remove_readd_with_device_config(qtbot): call(action="add", config=ANY, wait_for_response=False), ] ) + + +def test_cancel_config_action(qtbot): + ch = MagicMock(spec=ConfigHelper) + ch.send_config_request.return_value = "abcde" + cca = CommunicateConfigAction(config_helper=ch, device=None, config=None, action="cancel") + cca.run() + ch.send_config_request.assert_called_once_with( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 3dca50a20..59a207021 100644 --- a/tests/unit_tests/test_dark_mode_button.py +++ b/tests/unit_tests/test_dark_mode_button.py @@ -2,9 +2,8 @@ import pytest from qtpy.QtCore import Qt -from qtpy.QtWidgets import QApplication -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton # pylint: disable=unused-import @@ -21,7 +20,7 @@ def dark_mode_button(qtbot, mocked_client): button = DarkModeButton(client=mocked_client) qtbot.addWidget(button) qtbot.waitExposed(button) - set_theme("light") + apply_theme("light") yield button @@ -64,23 +63,10 @@ def test_dark_mode_button_changes_theme(dark_mode_button): Test that the dark mode button changes the theme correctly. """ with mock.patch( - "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.set_theme" - ) as mocked_set_theme: + "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.apply_theme" + ) as mocked_apply_theme: dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("dark") + mocked_apply_theme.assert_called_with("dark") dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("light") - - -def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button): - """ - Test that the dark mode button changes the theme correctly when the OS theme changes. - """ - qapp = QApplication.instance() - assert dark_mode_button.dark_mode_enabled is False - assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode" - qapp.theme_signal.theme_updated.emit("dark") - qtbot.wait(100) - assert dark_mode_button.dark_mode_enabled is True - assert dark_mode_button.mode_button.toolTip() == "Set Light Mode" + mocked_apply_theme.assert_called_with("light") diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py new file mode 100644 index 000000000..f4b756fcb --- /dev/null +++ b/tests/unit_tests/test_developer_view.py @@ -0,0 +1,424 @@ +""" +Unit tests for the Developer View widget. + +This module tests the DeveloperView widget functionality including: +- Widget initialization and setup +- Monaco editor integration +- IDE Explorer integration +- File operations (open, save, format) +- Context menu actions +- Toolbar functionality +""" + +import os +import tempfile +from unittest import mock + +import pytest +from qtpy.QtWidgets import QDialog + +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +from .client_mocks import mocked_client + + +@pytest.fixture +def developer_view(qtbot, mocked_client): + """Create a DeveloperWidget for testing.""" + widget = DeveloperWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_python_file(): + """Create a temporary Python file for testing.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write( + """# Test Python file +import os +import sys + +def test_function(): + return "Hello, World!" + +if __name__ == "__main__": + print(test_function()) +""" + ) + temp_file_path = f.name + + yield temp_file_path + + # Cleanup + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +@pytest.fixture +def mock_scan_control_dialog(): + """Mock the ScanControlDialog for testing.""" + with mock.patch( + "bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog" + ) as mock_dialog: + # Configure the mock dialog + mock_dialog_instance = mock.MagicMock() + mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.get_scan_code.return_value = ( + "scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)" + ) + mock_dialog.return_value = mock_dialog_instance + yield mock_dialog_instance + + +class TestDeveloperViewInitialization: + """Test developer view initialization and basic functionality.""" + + def test_developer_view_initialization(self, developer_view): + """Test that the developer view initializes correctly.""" + # Check that main components are created + assert hasattr(developer_view, "monaco") + assert hasattr(developer_view, "explorer") + assert hasattr(developer_view, "console") + assert hasattr(developer_view, "terminal") + assert hasattr(developer_view, "toolbar") + assert hasattr(developer_view, "dock_manager") + assert hasattr(developer_view, "plotting_ads") + assert hasattr(developer_view, "signature_help") + + def test_monaco_editor_integration(self, developer_view): + """Test that Monaco editor is properly integrated.""" + assert isinstance(developer_view.monaco, MonacoDock) + assert developer_view.monaco.parent() is not None + + def test_ide_explorer_integration(self, developer_view): + """Test that IDE Explorer is properly integrated.""" + assert isinstance(developer_view.explorer, IDEExplorer) + assert developer_view.explorer.parent() is not None + + def test_toolbar_components(self, developer_view): + """Test that toolbar components are properly set up.""" + assert developer_view.toolbar is not None + + # Check for expected toolbar actions + toolbar_components = developer_view.toolbar.components + expected_actions = ["save", "save_as", "run", "stop", "vim"] + + for action_name in expected_actions: + assert toolbar_components.exists(action_name) + + def test_dock_manager_setup(self, developer_view): + """Test that dock manager is properly configured.""" + assert developer_view.dock_manager is not None + + # Check that docks are added + dock_widgets = developer_view.dock_manager.dockWidgets() + assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal + + +class TestFileOperations: + """Test file operation functionality.""" + + def test_open_new_file(self, developer_view, temp_python_file, qtbot): + """Test opening a new file in the Monaco editor.""" + # Simulate opening a file through the IDE explorer signal + developer_view._open_new_file(temp_python_file, "scripts/local") + + # Wait for the file to be loaded + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file was opened + assert temp_python_file in developer_view.monaco._get_open_files() + + # Check that content was loaded (simplified check) + # Get the editor dock for the file and check its content + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + editor_widget = dock.widget() + assert "test_function" in editor_widget.get_text() + + def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot): + """Test that shared files are opened in read-only mode.""" + # Open file with shared scope + developer_view._open_new_file(temp_python_file, "scripts/shared") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file is set to read-only + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + monaco_widget = dock.widget() + # Check that the widget is in read-only mode + # This depends on MonacoWidget having a readonly property or method + assert monaco_widget is not None + + def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot): + """Test that file icons are assigned based on scope.""" + # Test script file icon + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that an icon was set (simplified check) + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + assert not dock.icon().isNull() + + def test_save_functionality(self, developer_view, qtbot): + """Test the save functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save() + mock_save.assert_called_once() + + def test_save_as_functionality(self, developer_view, qtbot): + """Test the save as functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save as test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save as action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save_as() + mock_save.assert_called_once_with(force_save_as=True) + + +class TestMonacoEditorIntegration: + """Test Monaco editor specific functionality.""" + + def test_vim_mode_toggle(self, developer_view, qtbot): + """Test vim mode toggle functionality.""" + # Test enabling vim mode + with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim: + developer_view.on_vim_triggered() + # The actual call depends on the checkbox state + mock_vim.assert_called_once() + + def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot): + """Test the Insert Scan context menu action.""" + # This functionality is handled by individual MonacoWidget instances + # Test that the dock has editor widgets + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + assert len(dock_widgets) >= 1 + + # Test on the first available editor + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + assert isinstance(monaco_widget, MonacoWidget) + + def test_context_menu_format_code(self, developer_view, qtbot): + """Test the Format Code context menu action.""" + # Get an editor widget from the dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Set some unformatted Python code + unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x" + monaco_widget.set_text(unformatted_code) + + qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000) + + # Test format action on the individual widget + with mock.patch.object(monaco_widget, "format") as mock_format: + monaco_widget.format() + mock_format.assert_called_once() + + def test_save_enabled_signal_handling(self, developer_view, qtbot): + """Test that save enabled signals are handled correctly.""" + # Mock the toolbar update method + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate save enabled signal + developer_view.monaco.save_enabled.emit(True) + mock_update.assert_called_with(True) + + developer_view.monaco.save_enabled.emit(False) + mock_update.assert_called_with(False) + + +class TestIDEExplorerIntegration: + """Test IDE Explorer integration.""" + + def test_file_open_signal_connection(self, developer_view): + """Test that file open signals are properly connected.""" + # Test that the signal connection works by mocking the connected method + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the signal to test the connection + developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local") + mock_open.assert_called_once_with("test_file.py", "scripts/local") + + def test_file_preview_signal_connection(self, developer_view): + """Test that file preview signals are properly connected.""" + # Test that the signal exists and can be emitted (basic connection test) + try: + developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local") + # If no exception is raised, the signal exists and is connectable + assert True + except AttributeError: + assert False, "file_preview_requested signal not found" + + def test_sections_configuration(self, developer_view): + """Test that IDE Explorer sections are properly configured.""" + assert "scripts" in developer_view.explorer.sections + assert "macros" in developer_view.explorer.sections + + +class TestToolbarIntegration: + """Test toolbar functionality and integration.""" + + def test_toolbar_save_button_state(self, developer_view): + """Test toolbar save button state management.""" + # Test that save buttons exist and can be controlled + save_action = developer_view.toolbar.components.get_action("save") + save_as_action = developer_view.toolbar.components.get_action("save_as") + + # Test that the actions exist and are accessible + assert save_action.action is not None + assert save_as_action.action is not None + + # Test that they can be enabled/disabled via the update method + developer_view._on_save_enabled_update(False) + assert not save_action.action.isEnabled() + assert not save_as_action.action.isEnabled() + + developer_view._on_save_enabled_update(True) + assert save_action.action.isEnabled() + assert save_as_action.action.isEnabled() + + def test_vim_mode_button_toggle(self, developer_view, qtbot): + """Test vim mode button toggle functionality.""" + vim_action = developer_view.toolbar.components.get_action("vim") + + if vim_action: + # Test toggling vim mode + initial_state = vim_action.action.isChecked() + + # Simulate button click + vim_action.action.trigger() + + # Check that state changed + assert vim_action.action.isChecked() != initial_state + + def test_run_stop_buttons_disabled_for_macros(self, developer_view, temp_python_file, qtbot): + """Test that run and stop buttons are disabled when a macro file is focused.""" + # Open a file with macro scope + developer_view._open_new_file(temp_python_file, "macros/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Get the editor dock for the macro file + dock = developer_view.monaco._get_editor_dock(temp_python_file) + assert dock is not None + + # Simulate focusing on the macro file + developer_view._on_focused_editor_changed(dock) + + # Check that run and stop buttons are disabled + run_action = developer_view.toolbar.components.get_action("run") + stop_action = developer_view.toolbar.components.get_action("stop") + + assert not run_action.action.isEnabled() + assert not stop_action.action.isEnabled() + + def test_run_stop_buttons_enabled_for_scripts(self, developer_view, temp_python_file, qtbot): + """Test that run and stop buttons are enabled when a script file is focused.""" + # Open a file with script scope + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Get the editor dock for the script file + dock = developer_view.monaco._get_editor_dock(temp_python_file) + assert dock is not None + + # Simulate focusing on the script file + developer_view._on_focused_editor_changed(dock) + + # Check that run and stop buttons are enabled + run_action = developer_view.toolbar.components.get_action("run") + stop_action = developer_view.toolbar.components.get_action("stop") + + assert run_action.action.isEnabled() + assert stop_action.action.isEnabled() + + +class TestErrorHandling: + """Test error handling in various scenarios.""" + + def test_invalid_scope_handling(self, developer_view, temp_python_file): + """Test handling of invalid scope parameters.""" + # Test with invalid scope + try: + developer_view._open_new_file(temp_python_file, "invalid/scope") + except Exception as e: + assert False, f"Invalid scope should be handled gracefully: {e}" + + def test_monaco_editor_error_handling(self, developer_view): + """Test error handling in Monaco editor operations.""" + # Test with editor widgets from dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Test setting invalid text + try: + monaco_widget.set_text(None) # This might cause an error + except Exception: + # Errors should be handled gracefully + pass + + +class TestSignalIntegration: + """Test signal connections and data flow.""" + + def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot): + """Test the complete file open signal flow.""" + # Mock the _open_new_file method to verify it gets called + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the file open signal from explorer + developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local") + + # Verify the signal was handled + mock_open.assert_called_once_with(temp_python_file, "scripts/local") + + def test_save_enabled_signal_flow(self, developer_view, qtbot): + """Test the save enabled signal flow.""" + # Mock the update method (the actual method is _on_save_enabled_update) + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate monaco dock emitting save enabled signal + developer_view.monaco.save_enabled.emit(True) + + # Verify the signal was handled + mock_update.assert_called_once_with(True) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8a..7c36594ee 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client): yield dev_browser -def test_device_browser_init_with_devices(device_browser): +def test_device_browser_init_with_devices(device_browser: DeviceBrowser): """ Test that the device browser is initialized with the correct number of devices. """ - device_list = device_browser.ui.device_list + device_list = device_browser.dev_list assert device_list.count() == len(device_browser.dev) @@ -58,11 +58,11 @@ def test_device_browser_filtering( expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev) def num_visible(item_dict): - return len(list(filter(lambda i: not i.isHidden(), item_dict.values()))) + return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values()))) device_browser.ui.filter_input.setText(search_term) qtbot.wait(100) - assert num_visible(device_browser._device_items) == expected + assert num_visible(device_browser.dev_list._item_dict) == expected def test_device_item_mouse_press_event(device_browser, qtbot): @@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot): Test that the mousePressEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton) @@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot): Test that the form is displayed when the item is expanded, and that the expansion is triggered by clicking on the expansion button, the title, or the device icon """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100) @@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot): form = tab_widget.widget(0).layout().itemAt(0).widget() assert widget.expanded assert (name_field := form.widget_dict.get("name")) is not None - qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500) + qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded @@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt """ Test that the mousePressEvent is triggered correctly and initiates a drag. """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) device_name = widget.device with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec: with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata: @@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot): Test that the mouseDoubleClickEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseDClick(widget, Qt.LeftButton) def test_device_deletion(device_browser, qtbot): - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) widget._config_helper = mock.MagicMock() - assert widget.device in device_browser._device_items + assert widget.device in device_browser.dev_list._item_dict qtbot.mouseClick(widget.delete_button, Qt.LeftButton) - qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000) + qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000) def test_signal_display(mocked_client, qtbot): diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index 54bcbe356..219350d2b 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -6,7 +6,7 @@ from bec_widgets.utils.forms_from_types.items import StrFormItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, _try_literal_eval, ) @@ -29,7 +29,7 @@ def mock_client(): @pytest.fixture def update_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - update_dialog = DeviceConfigDialog( + update_dialog = DirectUpdateDeviceConfigDialog( device="test_device", config_helper=MagicMock(), client=mock_client ) qtbot.addWidget(update_dialog) @@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot): @pytest.fixture def add_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - add_dialog = DeviceConfigDialog( + add_dialog = DirectUpdateDeviceConfigDialog( device=None, config_helper=MagicMock(), client=mock_client, action="add" ) qtbot.addWidget(add_dialog) diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py new file mode 100644 index 000000000..530b53c1b --- /dev/null +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -0,0 +1,68 @@ +# pylint skip-file +import pytest +from bec_lib.messages import DeviceInitializationProgressMessage + +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def progress_bar(qtbot, mocked_client): + widget = DeviceInitializationProgressBar(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_progress_bar_initialization(progress_bar): + """Test the initial state of the DeviceInitializationProgressBar.""" + assert progress_bar.failed_devices == [] + assert progress_bar.progress_bar._user_value == 0 + assert progress_bar.progress_bar._user_maximum == 100 + assert progress_bar.toolTip() == "No device initialization failures." + + assert progress_bar.progress_label.text() == "Initializing devices..." + assert progress_bar.group_box.title() == "Config Update Progress" + + +def test_update_device_initialization_progress(progress_bar, qtbot): + """Test updating the progress bar with different device initialization messages.""" + + # I. Update with message of running DeviceInitializationProgressMessage, finished=False, success=False + msg = DeviceInitializationProgressMessage( + device="DeviceA", index=1, total=3, finished=False, success=False + ) + + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar.progress_bar._user_value == 1 + assert progress_bar.progress_bar._user_maximum == 3 + assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..." + assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() + + # II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True + msg.finished = True + msg.success = True + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar.progress_bar._user_value == 1 + assert progress_bar.progress_bar._user_maximum == 3 + assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!" + assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() + + # III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False + msg.finished = True + msg.success = False + msg.device = "DeviceB" + msg.index = 2 + with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker: + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!" + assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text() + assert progress_bar.progress_bar._user_value == 2 + assert progress_bar.progress_bar._user_maximum == 3 + + assert signal_blocker.args == [[msg.device]] + + assert progress_bar.toolTip() == f"Failed devices: {msg.device}" diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 02ae550d8..52fac5302 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -9,6 +9,7 @@ DeviceInputBase, DeviceInputConfig, ) +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from .client_mocks import mocked_client from .conftest import create_widget @@ -43,7 +44,7 @@ def test_device_input_base_init(device_input_base): assert device_input_base.devices == [] -def test_device_input_base_init_with_config(mocked_client): +def test_device_input_base_init_with_config(qtbot, mocked_client): """Test init with Config""" config = { "widget_class": "DeviceInputWidget", @@ -55,6 +56,10 @@ def test_device_input_base_init_with_config(mocked_client): widget2 = DeviceInputWidget( client=mocked_client, config=DeviceInputConfig.model_validate(config) ) + qtbot.addWidget(widget) + qtbot.addWidget(widget2) + qtbot.waitExposed(widget) + qtbot.waitExposed(widget2) for w in [widget, widget2]: assert w.config.gui_id == "test_gui_id" assert w.config.device_filter == ["Positioner"] @@ -138,3 +143,24 @@ def test_device_input_base_properties(device_input_base): ReadoutPriority.MONITORED, ReadoutPriority.ON_REQUEST, ] + + +def test_device_combobox_signal_class_filter(qtbot, mocked_client): + """Test device filtering via signal_class_filter on combobox.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ("samx", "async_signal", {"signal_class": "AsyncSignal"}), + ("samy", "async_signal", {"signal_class": "AsyncSignal"}), + ("bpm4i", "async_signal", {"signal_class": "AsyncSignal"}), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=DeviceComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + ) + + devices = [widget.itemText(i) for i in range(widget.count())] + assert set(devices) == {"samx", "samy", "bpm4i"} + assert widget.signal_class_filter == ["AsyncSignal"] diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py new file mode 100644 index 000000000..771dc2df7 --- /dev/null +++ b/tests/unit_tests/test_device_manager_components.py @@ -0,0 +1,1665 @@ +"""Unit tests for device_manager_components module.""" + +from threading import Event +from typing import Generator +from unittest import mock + +import pytest +import yaml +from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import ( + OPHYD_DEVICE_TEMPLATES, + EpicsMotorDeviceConfigTemplate, +) +from ophyd_devices.utils.static_device_test import TestResult +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.bec_list import BECList +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.device_manager import DeviceTable, DMConfigView, DocstringView +from bec_widgets.widgets.control.device_manager.components import docstring_to_markdown +from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, +) +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, + _try_literal_eval, +) +from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + DeviceTest, + LegendLabel, + OphydValidation, + ThreadPoolManager, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.validation_list_item import ( + ValidationButton, + ValidationDialog, + ValidationListItem, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +from .client_mocks import mocked_client + + +class TestConstants: + """Test class for constants and configuration values.""" + + def test_headers_help_md(self): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "valid", + "connect", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value["long"], str) + assert isinstance(value["short"], str) + assert value["long"].startswith("## ") # Each entry should start with a markdown header + + +# Test utility classes for docstring testing +class NumPyStyleClass: + """Perform simple signal operations. + + Parameters + ---------- + data : numpy.ndarray + Input signal data. + + Attributes + ---------- + data : numpy.ndarray + The original signal data. + + Returns + ------- + SignalProcessor + An initialized signal processor instance. + """ + + +class GoogleStyleClass: + """Analyze spectral properties of a signal. + + Args: + frequencies (list[float]): Frequency bins. + amplitudes (list[float]): Corresponding amplitude values. + + Returns: + dict: A dictionary with spectral analysis results. + + Raises: + ValueError: If input lists are of unequal length. + """ + + +class TestDocstringView: + """Test class for DocstringView component.""" + + @pytest.fixture + def docstring_view(self, qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + def test_docstring_to_markdown(self): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting + + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting + + def test_on_select_config(self, docstring_view: DocstringView): + """Test the on_select_config method with a sample configuration.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, + ): + # Test with single device + docstring_view.on_select_config([{"test": {"deviceClass": "NumPyStyleClass"}}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [ + {"test": {"deviceClass": "NumPyStyleClass"}}, + {"test": {"deviceClass": "GoogleStyleClass"}}, + ] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + def test_set_device_class(self, docstring_view: DocstringView): + """Test the set_device_class method.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +class TestDMConfigView: + """Test class for DMConfigView component.""" + + @pytest.fixture + def dm_config_view(self, qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + def test_initialization(self, dm_config_view: DMConfigView): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select a single device to view its config." + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + def test_on_select_config(self, dm_config_view: DMConfigView): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +class TestDeviceTableRow: + """Test class for DeviceTableRow component.""" + + @pytest.fixture + def sample_device_data(self) -> dict: + """Sample device data for testing.""" + return { + "name": "test_motor", + "deviceClass": "ophyd.EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": {"motors", "positioning"}, + "description": "X-axis positioning motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + + @pytest.fixture + def device_table_row(self, sample_device_data: dict): + """Fixture to create a DeviceTableRow instance.""" + row = DeviceTableRow(data=sample_device_data) + yield row + + def test_initialization(self, device_table_row: DeviceTableRow, sample_device_data: dict): + """Test DeviceTableRow initialization with sample data.""" + expected_keys = list(DeviceModel.model_fields.keys()) + for key in expected_keys: + assert key in device_table_row.data + if key in sample_device_data: + assert device_table_row.data[key] == sample_device_data[key] + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, + ) + device_table_row.set_validation_status(ConfigStatus.VALID, ConnectionStatus.CONNECTED) + assert device_table_row.validation_status == ( + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + new_data = sample_device_data.copy() + new_data["name"] = "updated_motor" + device_table_row.set_data(new_data) + assert device_table_row.data["name"] == new_data.get("name", "") + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, + ) + + +class TestDeviceTable: + """Test class for DeviceTable component.""" + + @pytest.fixture + def device_table(self, qtbot, mocked_client) -> Generator[DeviceTable, None, None]: + """Fixture to create a DeviceTable instance.""" + table = DeviceTable(client=mocked_client) + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table + + @pytest.fixture + def sample_devices(self): + """Sample device configurations for testing.""" + return [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] + + def test_initialization(self, device_table: DeviceTable): + """Test DeviceTable initialization.""" + # Check table setup + assert device_table.table.columnCount() == 11 + assert device_table.table.rowCount() == 0 + + # Check headers + expected_headers = [ + "Valid", + "Connect", + "Name", + "Device Class", + "Readout Priority", + "On Failure", + "Device Tags", + "Description", + "Enabled", + "Read Only", + "Software Trigger", + ] + for i, expected_header in enumerate(expected_headers): + actual_header = device_table.table.horizontalHeaderItem(i).text() + assert actual_header == expected_header + + # Check search functionality is set up + assert device_table.search_input is not None + assert device_table.fuzzy_is_disabled.isChecked() is False + assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows + assert hasattr(device_table, "client_callback_id") + + def test_device_table_client_device_update_callback( + self, device_table: DeviceTable, mocked_client, qtbot + ): + """ + Test that runs the client device update callback. This should update the status of devices in the table + that are in sync with the client. + + I. First test will run a callback when no devices are in the table, should do nothing. + II. Second test will add devices all devices from the mocked_client, then remove one + device from the client and run the callback. The table should update the status of the + removed device to CAN_CONNECT and all others to CONNECTED. + """ + device_configs_changed_calls = [] + requested_update_for_multiple_device_validations = [] + + def _device_configs_changed_cb(cfgs: list[dict], added: bool, skip_validation: bool): + """Callback to capture device config changes.""" + device_configs_changed_calls.append((cfgs, added, skip_validation)) + + def _requested_update_for_multiple_device_validations_cb(device_names: list): + """Callback to capture requests for multiple device validations.""" + requested_update_for_multiple_device_validations.append(device_names) + + device_table.device_configs_changed.connect(_device_configs_changed_cb) + device_table.request_update_multiple_device_validations.connect( + _requested_update_for_multiple_device_validations_cb + ) + + # I. First test case with no devices in the table + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + assert not device_configs_changed_calls + assert not requested_update_for_multiple_device_validations + + # II. Second test case, add all devices from mocked client to table + # Add all devices from mocked client to table. + device_configs = mocked_client.device_manager._get_redis_device_config() + device_table.add_device_configs(device_configs, skip_validation=True) + mocked_client.device_manager.devices.pop("samx") # Remove samx from client + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + validation_results = { + cfg.get("name"): ( + DeviceModel.model_validate(cfg).model_dump(), + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + for cfg in device_configs + } + with mock.patch.object( + device_table, "get_validation_results", return_value=validation_results + ): + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + # One for add_device_configs, one for the update + assert len(device_configs_changed_calls) == 2 + # The first call should have one more device than the second + assert ( + len(device_configs_changed_calls[0][0]) + - len(device_configs_changed_calls[1][0]) + == 1 + ) + # Only one device should have been marked for validation update + assert len(requested_update_for_multiple_device_validations) == 1 + assert len(requested_update_for_multiple_device_validations[0]) == 1 + + def test_add_row(self, device_table: DeviceTable, sample_devices: dict): + """Test adding a single device row.""" + device_table.add_device_configs([sample_devices[0]]) + + # Verify row was added + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + assert "motor_x" in device_table.row_data + + # If row is added again, it should overwrite + sample_devices[0]["deviceClass"] = "UpdateClass" + device_table.add_device_configs([sample_devices[0]]) + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + row_data = device_table.row_data["motor_x"] + assert row_data is not None + assert row_data.data.get("deviceClass") == "UpdateClass" + assert device_table._get_cell_data(0, 3) == "UpdateClass" # DeviceClass column + assert device_table._get_cell_data(0, 2) == "motor_x" # Name column + assert device_table._get_cell_data(0, 0) == "" # Icon column, no text + assert device_table._get_cell_data(0, 9) == False # Check Enabled column + assert device_table.table.item(0, 9).checkState() == QtCore.Qt.CheckState.Unchecked + config_status_item = device_table.table.item(0, 0) + assert ( + config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.UNKNOWN.value + ) + + def test_update_row(self, device_table: DeviceTable, sample_devices: dict): + """Test updating an existing device row.""" + device_table.add_device_configs([sample_devices[0]]) + + assert "motor_x" in device_table.row_data + # Update the existing row + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "X-axis motor" + # Change description + sample_devices[0]["description"] = "Updated X-axis motor" + device_table._update_row(sample_devices[0]) + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "Updated X-axis motor" + assert row.validation_status == (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + # Update validation status + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + validation_msg="", + ) + assert row.validation_status == (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value) + config_status_item = device_table.table.item(0, 0) + assert config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.VALID.value + + ##################### + ##### Test public API + ##################### + + def test_set_device_config(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test set device configs methods, must also emit the appropriate signal.""" + with mock.patch.object(device_table, "clear_device_configs") as mock_clear_configs: + ########### + # Test cases I. + # First use case, adding new configs to empty table + device_table.set_device_config(sample_devices) + + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 1 + + # II. + # Second use case, replacing existing configs + device_table.set_device_config(sample_devices) + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 2 + + def test_clear_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test clearing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.clear_device_configs() + + assert len(container) == 1 + assert device_table.table.rowCount() == 0 + + def test_add_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test add device configs method under various scenarios.""" + + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + assert device_table.table.rowCount() == 2 + + # II. + # If added again, old configs should be removed first, and new ones added + # Reset container + container = [] + expected_calls = 2 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 2 + assert container[0][0][1] is False + assert container[1][0][0] == sample_devices + assert container[1][0][1] is True + + # Verify rows were added + assert device_table.table.rowCount() == 2 + assert len(device_table.row_data) == 2 + assert "motor_x" in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_update_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test updating device configurations.""" + + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + # First case I. + # Update to empty table should add rows, and emit signal with added=True + expected_calls = 1 + + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls) as blocker: + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + + # Second case II. + # Update existing configs should modify rows, and change the validation status to unknown + # for the device that was changed + container = [] + sample_devices[0]["description"] = "Modified description" + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == [sample_devices[0]] + assert container[0][0][1] is True + + def test_get_device_config(self, device_table: DeviceTable, sample_devices: dict): + """Test retrieving device configurations.""" + device_table.add_device_configs(sample_devices) + + retrieved_configs = device_table.get_device_config() + assert len(retrieved_configs) == 2 + + # Check that we can find our test devices + device_names = [config["name"] for config in retrieved_configs] + assert "motor_x" in device_names + assert "detector_main" in device_names + + def test_search_functionality(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test search/filter functionality.""" + device_table.add_device_configs(sample_devices) + + # Test filtering by name + qtbot.keyClicks(device_table.search_input, "motor") + qtbot.wait(100) # Allow filter to apply + + # Should show only motor device + visible_rows = 0 + for row in range(device_table.table.rowCount()): + if not device_table.table.isRowHidden(row): + visible_rows += 1 + assert visible_rows == 1 + + def test_remove_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test removing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + + # Remove one device + with qtbot.waitSignal(device_table.device_configs_changed) as blocker: + device_table.remove_device_configs([sample_devices[0]]) + + # Verify signal emission + emitted_configs, added, skip_validation = blocker.args + assert len(emitted_configs) == 1 + assert added is False + assert skip_validation is True + + # Verify row was removed + assert device_table.table.rowCount() == 1 + assert "motor_x" not in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_validation_status_update(self, device_table: DeviceTable, sample_devices: dict): + """Test updating validation status.""" + device_table: DeviceTable + device_table.add_device_configs(sample_devices) + + # Update validation status for one device + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + validation_msg="Test passed", + ) + + # Verify status was updated in the row + motor_row = device_table.row_data["motor_x"] + assert motor_row.validation_status == (ConfigStatus.VALID, ConnectionStatus.CONNECTED) + + def test_selection_handling(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test device selection and signal emission.""" + device_table.add_device_configs(sample_devices) + + # Select first row + with qtbot.waitSignal(device_table.selected_devices) as blocker: + device_table.table.selectRow(0) + + # Verify selection signal was emitted + selected_configs = blocker.args[0] + assert len(selected_configs) == 1 + assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"] + + +class TestOphydValidation: + """ + Test class for the Ophyd test module. This tests the OphydValidation widget, + the validation list items and dialog, and the utility functions related to + device testing and validation. + """ + + ################ + ### Ophyd_test_utils tests + ################ + + def test_format_error_to_md(self): + """Test the format_error_to_md utility function.""" + device_name = "non_existing_device" + error_msg = """ERROR: non_existing_device is not valid: 3 validation errors for Device\nenabled\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\ndeviceClass\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nreadoutPriority\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nERROR: non_existing_device is not valid: 'deviceClass'""" + md_output = format_error_to_md(device_name, error_msg) + assert f"## Error for {device_name}\n\n**{device_name} is not valid**" in md_output + assert "3 validation errors for Device" in md_output + + def test_description_validation_status(self): + """Test descriptions for ConfigStatus enum values.""" + # ConfigStatus descriptions + assert ConfigStatus.VALID.description() == "Valid Configuration" + assert ConfigStatus.INVALID.description() == "Invalid Configuration" + assert ConfigStatus.UNKNOWN.description() == "Unknown" + + # ConnectionStatus descriptions + assert ConnectionStatus.CANNOT_CONNECT.description() == "Cannot Connect" + assert ConnectionStatus.CAN_CONNECT.description() == "Can Connect" + assert ConnectionStatus.CONNECTED.description() == "Connected and Loaded" + assert ConnectionStatus.UNKNOWN.description() == "Unknown" + + def test_device_test_model(self): + """Test the DeviceTestModel""" + data = { + "uuid": "1234", + "device_name": "test_device", + "device_config": {"name": "test_device", "deviceClass": "TestClass"}, + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + assert model.uuid == "1234" + assert model.device_name == "test_device" + + def test_get_validation_icons(self): + """Test the get_validation_icons utility function.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + + # Check that icons for all statuses are present + for status in ConfigStatus: + assert status in icons["config_status"] + assert isinstance(icons["config_status"][status], QtGui.QIcon) + + for status in ConnectionStatus: + assert status in icons["connection_status"] + assert isinstance(icons["connection_status"][status], QtGui.QIcon) + + ################ + ### ValidationListItem tests + ################ + + @pytest.fixture + def validation_button(self, qtbot): + """Fixture to create a ValidationButton instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + icon = icons["config_status"][ConfigStatus.VALID.value] + button = ValidationButton(icon=icon) + + qtbot.addWidget(button) + qtbot.waitExposed(button) + yield button + + def test_validation_button_initialization(self, validation_button: ValidationButton): + """Test ValidationButton initialization.""" + assert validation_button.isFlat() is True + assert validation_button.isEnabled() is True + assert isinstance(validation_button.icon(), QtGui.QIcon) + assert validation_button.styleSheet() == "" + validation_button.setEnabled(False) + assert validation_button.styleSheet() == "" + + @pytest.fixture + def validation_dialog(self, qtbot): + """Fixture for ValidationDialog.""" + dialog = ValidationDialog() + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + def test_validation_dialog(self, validation_dialog: ValidationDialog, qtbot): + """Test ValidationDialog initialization.""" + assert validation_dialog.timeout_spin.value() == 5 + assert validation_dialog.connect_checkbox.isChecked() is False + assert validation_dialog.force_connect_checkbox.isChecked() is False + + # Change timeout + validation_dialog.timeout_spin.setValue(10) + # Result should not update yet + assert validation_dialog.result() == (5, False, False) + # Click accept + with qtbot.waitSignal(validation_dialog.accepted): + qtbot.mouseClick( + validation_dialog.button_box.button(QtWidgets.QDialogButtonBox.Ok), + QtCore.Qt.LeftButton, + ) + assert validation_dialog.result() == (10, False, False) + + @pytest.fixture + def device_model(self): + """Fixture to create a sample DeviceTestModel instance.""" + config = DeviceModel( + name="test_device", deviceClass="TestClass", readoutPriority="baseline", enabled=True + ) + data = { + "uuid": "1234", + "device_name": config.name, + "device_config": config.model_dump(), + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + yield model + + @pytest.fixture + def validation_list_item(self, device_model, qtbot): + """Fixture to create a ValidationListItem instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + item = ValidationListItem(device_model=device_model, validation_icons=icons) + qtbot.addWidget(item) + qtbot.waitExposed(item) + yield item + + def test_update_validation_status(self, validation_list_item: ValidationListItem): + """Test updating status in ValidationListItem.""" + # Update to invalid config status + validation_list_item._update_validation_status( + validation_msg="Error occurred", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, + ) + assert validation_list_item.device_model.config_status == ConfigStatus.INVALID.value + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CANNOT_CONNECT.value + ) + assert validation_list_item.device_model.validation_msg == "Error occurred" + + def test_validation_logic(self, validation_list_item: ValidationListItem): + """Test starting and stopping validation spinner.""" + # Schedule validation + validation_list_item.validation_scheduled() + assert validation_list_item.status_button.isEnabled() is False + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.is_running is False + + # Start validation + with mock.patch.object(validation_list_item._spinner, "start") as mock_spinner_start: + validation_list_item.start_validation() + assert validation_list_item.is_running is True + mock_spinner_start.assert_called_once() + + # Finish validation + + with mock.patch.object(validation_list_item._spinner, "stop") as mock_spinner_stop: + + # I. successful validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.VALID.value, + connection_status=ConnectionStatus.CAN_CONNECT.value, + ) + assert validation_list_item.is_running is False + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CAN_CONNECT.value + ) + mock_spinner_stop.assert_called_once() + # Buttons should be disabled after validation finished good + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.status_button.isEnabled() is False + + # Restart validation + validation_list_item.start_validation() + mock_spinner_stop.reset_mock() + + # II. failed validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.UNKNOWN.value, + ) + assert validation_list_item.is_running is False + mock_spinner_stop.assert_called_once() + assert validation_list_item.connection_button.isEnabled() is True + assert validation_list_item.status_button.isEnabled() is True + + #################### + ### OphydValidation widget tests + #################### + + @pytest.fixture + def device_test_runnable(self, device_model, qtbot): + """Fixture to create a DeviceTest instance.""" + widget = QtWidgets.QWidget() # Create a widget because the runnable is not a widget itself + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + widget._runnable_test = DeviceTest( + device_model=device_model, timeout=5, enable_connect=True, force_connect=False + ) + yield widget + + def test_device_test(self, device_test_runnable, qtbot): + """Test DeviceTest runnable initialization.""" + runnable: DeviceTest = device_test_runnable._runnable_test + assert runnable.device_config.get("name") == "test_device" + assert runnable.timeout == 5 + assert runnable.enable_connect is True + assert runnable._cancelled is False + + # Callback validation + container = [] + + def _runnable_callback( + config: dict, config_is_valid: bool, connection_status: bool, error_msg: str + ): + container.append((config, config_is_valid, connection_status, error_msg)) + + runnable.signals.device_validated.connect(_runnable_callback) + + # Callback started + started_container = [] + + def _runnable_started_callback(): + started_container.append(True) + + runnable.signals.device_validation_started.connect(_runnable_started_callback) + + # Should resolve without running test if cancelled + runnable.cancel() + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.UNKNOWN.value + assert connection_status == ConnectionStatus.UNKNOWN.value + assert error_msg == f"{runnable.device_config.get('name', '')} was cancelled by user." + + # Now we run it without cancelling + + # Reset containers + container = [] + started_container = [] + runnable._cancelled = False + with mock.patch.object( + runnable.tester, "run_with_list_output" + ) as mock_run_with_list_output: + mock_run_with_list_output.return_value = [ + TestResult( + name="test_device", + config_is_valid=ConfigStatus.VALID.value, + success=ConnectionStatus.CANNOT_CONNECT.value, + message="All good", + ) + ] + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.VALID.value + assert connection_status == ConnectionStatus.CANNOT_CONNECT.value + assert error_msg == "All good" + + @pytest.fixture + def thread_pool_manager(self, qtbot): + """Fixture to create a ThreadPoolManager instance.""" + widget = QtWidgets.QWidget() # Create a widget because the manager is not a widget itself + widget._pool_manager = ThreadPoolManager() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_thread_pool_manager(self, thread_pool_manager): + """Test ThreadPoolManager initialization.""" + manager: ThreadPoolManager = thread_pool_manager._pool_manager + assert manager.pool.maxThreadCount() == 4 + assert manager._timer.interval() == 100 + + # Test submitting tasks + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + assert len(manager.get_scheduled_tests()) == 2 + assert len(manager.get_active_tests()) == 0 + + # Clear queue + manager.clear_queue() + assert device_test_mock_1.cancel.call_count == 1 + assert device_test_mock_2.cancel.call_count == 1 + assert device_test_mock_1.signals.device_validated.disconnect.call_count == 1 + assert device_test_mock_2.signals.device_validated.disconnect.call_count == 1 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 0 + + def test_thread_pool_process_queue(self, thread_pool_manager, qtbot): + """Test ThreadPoolManager process queue logic.""" + # Submit 2 elements to the queue + manager: ThreadPoolManager = thread_pool_manager._pool_manager + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + + # Validations running cb + container = [] + + def _validations_running_cb(is_true: bool): + container.append(is_true) + + manager.validations_are_running.connect(_validations_running_cb) + with mock.patch.object(manager.pool, "start") as mock_pool_start: + with qtbot.waitSignal(manager.validations_are_running): + # Process queue, should start both tasks + manager._process_queue() + assert mock_pool_start.call_count == 2 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 2 + assert len(container) == 1 + assert container[0] is True + device_test_mock_1.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + device_test_mock_2.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + + # Simulate one task finished + manager._on_task_finished({"name": "test_device"}, True, True, "All good") + assert len(manager.get_active_tests()) == 1 + + # Process queue again, nothing should happen as queue is empty + mock_pool_start.reset_mock() + manager._process_queue() + assert mock_pool_start.call_count == 0 + assert len(manager.get_active_tests()) == 1 + + @pytest.fixture + def legend_label(self, qtbot): + """Fixture to create a TestLegendLabel instance.""" + label = LegendLabel() + qtbot.addWidget(label) + qtbot.waitExposed(label) + yield label + + def test_legend_label(self, legend_label: LegendLabel): + """Test LegendLabel.""" + layout: QtWidgets.QGridLayout = legend_label.layout() + # Verify layout structure + assert layout.rowCount() == 2 + assert layout.columnCount() == 6 + # Assert labels and icons are present + label = layout.itemAtPosition(0, 0).widget() + assert label.text() == "Config Legend:" + label = layout.itemAtPosition(1, 0).widget() + assert label.text() == "Connect Legend:" + + @pytest.fixture + def ophyd_test(self, qtbot, mocked_client): + """Fixture to create an OphydValidation instance. We patch the method that starts the polling loop to avoid side effects.""" + with ( + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop", + return_value=None, + ), + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session", + return_value=False, + ), + ): + widget = OphydValidation(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_ophyd_test_initialization(self, ophyd_test: OphydValidation, qtbot): + """Test OphydValidation widget initialization.""" + assert isinstance(ophyd_test.list_widget, BECList) + assert isinstance(ophyd_test.thread_pool_manager, ThreadPoolManager) + layout = ophyd_test.layout() + # Widget with layout + legend label + assert isinstance(layout.itemAt(1).widget(), LegendLabel) + + # Test clicking the stop validation button + click_event = Event() + + def _stop_validation_button_clicked(): + click_event.set() + + ophyd_test._stop_validation_button.clicked.connect(_stop_validation_button_clicked) + with qtbot.waitSignal(ophyd_test._stop_validation_button.clicked): + # Simulate click + qtbot.mouseClick(ophyd_test._stop_validation_button, QtCore.Qt.LeftButton) + assert click_event.is_set() + + def test_ophyd_test_keep_visible_after_validation(self, ophyd_test: OphydValidation, qtbot): + """Test the keep visible after validation logic.""" + # Initially false + assert len(ophyd_test._keep_visible_after_validation) == 0 + + # Add device to keep visible + ophyd_test.add_device_to_keep_visible_after_validation("device_1") + assert "device_1" in ophyd_test._keep_visible_after_validation + # Add second device + ophyd_test.add_device_to_keep_visible_after_validation("device_2") + assert "device_2" in ophyd_test._keep_visible_after_validation + assert len(ophyd_test._keep_visible_after_validation) == 2 + + # Remove device + ophyd_test.remove_device_to_keep_visible_after_validation("device_1") + assert "device_1" not in ophyd_test._keep_visible_after_validation + assert "device_2" in ophyd_test._keep_visible_after_validation + + # Change config with skip validation and device in keep visible list + with ( + mock.patch.object( + ophyd_test, "_is_device_in_redis_session", return_value=True + ) as mock_is_device_in_redis_session, + mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config, + mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget, + ): + ophyd_test.change_device_configs( + [{"name": "device_2", "deviceClass": "TestClass"}], + added=True, + skip_validation=False, + ) + mock_add_device_config.assert_called_once() + mock_get_widget.assert_called_once_with("device_2") + + def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): + """Test adding devices to OphydValidation widget.""" + sample_devices = [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] + # Initially empty, add devices + with mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + + # Adding again should overwrite existing ones + with mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_configs: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + assert mock_remove_configs.call_count == 2 # Once for each device + + # Click item in list + item = ophyd_test.list_widget.item(0) + with qtbot.waitSignal(ophyd_test.item_clicked) as blocker: + qtbot.mouseClick( + ophyd_test.list_widget.viewport(), + QtCore.Qt.LeftButton, + pos=ophyd_test.list_widget.visualItemRect(item).center(), + ) + device_name = blocker.args[0] + assert ( + ophyd_test.list_widget.get_widget_for_item(item).device_model.device_name + == device_name + ) + + # Clear running validation + with ( + mock.patch.object( + ophyd_test.thread_pool_manager, "clear_device_in_queue" + ) as mock_clear, + mock.patch.object(ophyd_test, "_on_device_test_completed") as mock_on_completed, + ): + ophyd_test.cancel_validation("motor_x") + mock_clear.assert_called_once_with("motor_x") + mock_on_completed.assert_called_once_with( + sample_devices[0], + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "motor_x was cancelled by user.", + ) + + def test_ophyd_test_submit_test( + self, ophyd_test: OphydValidation, validation_list_item: ValidationListItem, qtbot + ): + """Test submitting a device test to the thread pool manager.""" + with ( + mock.patch.object( + validation_list_item, "validation_scheduled" + ) as mock_validation_scheduled, + mock.patch.object(ophyd_test.thread_pool_manager, "submit") as mock_thread_pool_submit, + ): + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_called_once() + mock_thread_pool_submit.assert_called_once() + + mock_validation_scheduled.reset_mock() + mock_thread_pool_submit.reset_mock() + # Assume device is already in Redis + with ( + mock.patch.object(ophyd_test, "_is_device_in_redis_session") as mock_in_redis, + mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_device, + ): + mock_in_redis.return_value = True + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_not_called() + mock_thread_pool_submit.assert_not_called() + assert validation_list_item.device_model.device_config == blocker.args[0] + assert blocker.args[1] is ConfigStatus.VALID.value + assert blocker.args[2] is ConnectionStatus.CONNECTED.value + + def test_ophyd_test_compare_device_configs(self, ophyd_test: OphydValidation): + """Test comparing device configurations.""" + device_config_1 = { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + device_config_2 = device_config_1.copy() + # Should be equal + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is True + + # Change a field + device_config_2["description"] = "Modified description" + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is False + + @pytest.mark.parametrize( + "config_status,connection_status, msg", + [ + (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value, "Validation successful"), + ( + ConfigStatus.INVALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + "Validation failed", + ), + ], + ) + def test_ophyd_test_validation_succeeds( + self, ophyd_test: OphydValidation, qtbot, config_status, connection_status, msg + ): + """Test handling of successful device validation.""" + sample_device = { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + with ( + mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test, + mock.patch.object(ophyd_test, "_is_device_in_redis_session", return_value=False), + ): + ophyd_test.change_device_configs([sample_device], added=True) + + # Emit validation completed signal from thread pool manager + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + validation_item = ophyd_test.list_widget.get_widget_for_item( + ophyd_test.list_widget.item(0) + ) + with mock.patch.object( + validation_item, "on_validation_finished" + ) as mock_on_validation_finished: + ophyd_test.thread_pool_manager.device_validated.emit( + sample_device, config_status, connection_status, msg + ) + if config_status != ConfigStatus.VALID.value: + mock_on_validation_finished.assert_called_once_with( + validation_msg=msg, + config_status=config_status, + connection_status=connection_status, + ) + + assert blocker.args[0] == sample_device + assert blocker.args[1] == config_status + assert blocker.args[2] == connection_status + assert blocker.args[3] == msg + + +class TestDeviceConfigTemplate: + + def test_try_literal_eval(self): + """Test the _try_literal_eval static method.""" + # handle booleans + assert _try_literal_eval("True") is True + assert _try_literal_eval("False") is False + assert _try_literal_eval("true") is True + assert _try_literal_eval("false") is False + # handle empty string + assert _try_literal_eval("") == "" + # Lists + assert _try_literal_eval([0, 1, 2]) == [0, 1, 2] + # Set and tuples + assert _try_literal_eval((1, 2, 3)) == (1, 2, 3) + # Numbers int and float + assert _try_literal_eval("123") == 123 + assert _try_literal_eval("45.67") == 45.67 + # if literal eval fails, return original string + assert _try_literal_eval(" invalid text,,, ") == " invalid text,,, " + + def _create_widget_for_device_field(self, field_name: str, qtbot) -> QtWidgets.QWidget: + """Helper method to create a widget for a given device field.""" + field = DEVICE_FIELDS[field_name] + widget = field.widget_cls() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + def test_device_fields_name(self, qtbot): + """Test DEVICE_FIELDS content for 'name' field.""" + colors = get_accent_colors() + name_field: DeviceConfigField = DEVICE_FIELDS["name"] + assert name_field.label == "Name" + assert name_field.widget_cls == InputLineEdit + assert name_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("name", qtbot) + if name_field.validation_callback is not None: + for cb in name_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("valid_device_name") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("invalid _name") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_device_class(self, qtbot): + """Test DEVICE_FIELDS content for 'deviceClass' field.""" + colors = get_accent_colors() + device_class_field: DeviceConfigField = DEVICE_FIELDS["deviceClass"] + assert device_class_field.label == "Device Class" + assert device_class_field.widget_cls == InputLineEdit + assert device_class_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("deviceClass", qtbot) + if device_class_field.validation_callback is not None: + for cb in device_class_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("EpicsMotor") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("wrlong-sadnjkas:'&") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_description(self, qtbot): + """Test DEVICE_FIELDS content for 'description' field.""" + description_field: DeviceConfigField = DEVICE_FIELDS["description"] + assert description_field.label == "Description" + assert description_field.widget_cls == QtWidgets.QTextEdit + assert description_field.required is False + assert description_field.placeholder_text == "Short device description" + # Create widget and test + widget: QtWidgets.QTextEdit = self._create_widget_for_device_field("description", qtbot) + + def test_device_fields_toggle_fields(self, qtbot): + """Test DEVICE_FIELDS content for 'enabled' and 'readOnly' fields.""" + for field_name in ["enabled", "readOnly", "softwareTrigger"]: + field: DeviceConfigField = DEVICE_FIELDS[field_name] + assert field.label in ["Enabled", "Read Only", "Software Trigger"] + assert field.widget_cls == ToggleSwitch + assert field.required is False + if field_name == "enabled": + assert field.default is True + else: + assert field.default is False + + @pytest.fixture + def device_config_template(self, qtbot): + """Fixture to create a DeviceConfigTemplate instance.""" + template = DeviceConfigTemplate() + qtbot.addWidget(template) + qtbot.waitExposed(template) + yield template + + def test_device_config_teamplate_default_init( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test DeviceConfigTemplate default initialization.""" + assert ( + device_config_template.template + == OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + ) + + # Check settings box, should have 3 labels, 2 InputLineEdit, 1 QTextEdit + assert len(device_config_template.settings_box.findChildren(QtWidgets.QLabel)) == 3 + assert len(device_config_template.settings_box.findChildren(InputLineEdit)) == 2 + assert len(device_config_template.settings_box.findChildren(QtWidgets.QTextEdit)) == 1 + + # Check advanced control box, should have 5 labels for + # readoutPriority, onFailure, enabled, readOnly, softwareTrigger + assert len(device_config_template.advanced_control_box.findChildren(QtWidgets.QLabel)) == 5 + assert len(device_config_template.advanced_control_box.findChildren(ToggleSwitch)) == 3 + assert ( + len(device_config_template.advanced_control_box.findChildren(ReadoutPriorityComboBox)) + == 1 + ) + assert len(device_config_template.advanced_control_box.findChildren(OnFailureComboBox)) == 1 + + # Check connection box for CustomDevice, should be empty dict. + assert isinstance( + device_config_template.connection_settings_box.layout().itemAt(0).widget(), + ParameterValueWidget, + ) + + # Check additional settings box for CustomDevice, should be empty dict. + tool_box = device_config_template.additional_settings_box.layout().itemAt(0).widget() + assert isinstance(tool_box, QtWidgets.QToolBox) + assert isinstance(device_config_template._widgets["userParameter"], ParameterValueWidget) + assert isinstance(device_config_template._widgets["deviceTags"], DeviceTagsWidget) + + # Check default values and proper widgets in _widgets dict + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + assert isinstance(widget, ParameterValueWidget) + assert widget.parameters() == {} # Default empty dict for CustomDevice template + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_epics_motor( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test the DeviceConfigTemplate for the EpicsMotor device class.""" + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + + # Check that all widgets are created properly + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + for sub_field, sub_widget in widget.items(): + if sub_field in DEVICE_CONFIG_FIELDS: + field = DEVICE_CONFIG_FIELDS[sub_field] + assert isinstance(sub_widget, field.widget_cls) + if sub_field == "limits": + # Limits is LimitInputWidget + sub_widget: LimitInputWidget + assert sub_widget.get_limits() == [0, 0] # Default limits + else: + assert isinstance(widget, InputLineEdit) + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_get_set_config( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + # Test get config for default Custom Device template + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + config = device_config_template.get_config_fields() + for k, v in OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"].items(): + if k == "deviceConfig": + v: EpicsMotorDeviceConfigTemplate + v.model_validate(config["deviceConfig"]) + continue + if isinstance(v, (list, tuple)): + v = tuple(v) + config_value = config[k] + if isinstance(config_value, (list, tuple)): + config_value = tuple(config_value) + assert config_value == v + + # Set config from Model for custom EpicsMotor + model = DeviceModel( + name="motor_x", + deviceClass="ophyd.EpicsMotor", + readoutPriority="baseline", + enabled=False, + deviceConfig={"prefix": "MOTOR_X:", "limits": [-10, 10], "additional_field": 42}, + deviceTags=["motors", "x_axis"], + userParameter={"param1": 100, "param2": "value2"}, + ) + device_config_template.set_config_fields(model.model_dump()) + # Check config + config = device_config_template.get_config_fields() + assert config["name"] == "motor_x" + assert config["deviceClass"] == "ophyd.EpicsMotor" + assert config["readoutPriority"] == "baseline" + assert config["enabled"] is False + assert config["deviceConfig"] == { + "prefix": "MOTOR_X:", + "limits": [-10, 10], + "additional_field": 42, + } + assert set(config["deviceTags"]) == {"motors", "x_axis"} + assert config["userParameter"] == {"param1": 100, "param2": "value2"} + + def test_limit_input_widget(self, qtbot): + """Test LimitInputWidget functionality.""" + colors = get_accent_colors() + widget = LimitInputWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Default limits should be [0, 0] + assert widget.get_limits() == [0, 0] + + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Set limits externally + widget.set_limits([-5, 5]) + assert widget.get_limits() == [-5, 5] + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Enable toggle + with qtbot.waitSignal(widget.enable_toggle.stateChanged): + widget.enable_toggle.setChecked(True) + + assert widget.enable_toggle.isChecked() is True + # Set invalid limits (min >= max) + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(2) + widget.max_input.setValue(1) + + assert widget._is_valid_limit() is False + assert widget.min_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + assert widget.max_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + # Reset to default values + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(0) + widget.max_input.setValue(0) + + assert widget.get_limits() == [0, 0] + assert widget.min_input.styleSheet() == "" + assert widget.max_input.styleSheet() == "" + + def test_parameter_value_widget(self, qtbot): + """Test ParameterValueWidget functionality.""" + widget = ParameterValueWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no parameters + assert widget.parameters() == {} + + # Add parameters + sample_params = {"param1": 10, "param2": "value", "param3": True} + for k, v in sample_params.items(): + widget.add_parameter_line(k, v) + assert widget.parameters() == sample_params + + # Modify a parameter + param1_widget: InputLineEdit = widget.tree_widget.itemWidget( + widget.tree_widget.topLevelItem(0), 1 + ) + with qtbot.waitSignal(param1_widget.textChanged): + param1_widget.setText("20") + updated_params = widget.parameters() + assert updated_params["param1"] == 20 + assert updated_params["param2"] == "value" + assert updated_params["param3"] is True + # Select top item + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + widget.remove_parameter_line() + # Check that param1 is removed + assert widget.parameters() == {"param2": "value", "param3": True} + # Clear all parameters + widget.clear_widget() + assert widget.parameters() == {} + + def test_device_tags_widget(self, qtbot): + """Test DeviceTagsWidget functionality.""" + widget = DeviceTagsWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no tags + assert widget.parameters() == [] + + # Add tags + with qtbot.waitSignal(widget._button_add.clicked): + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 2 + assert widget.parameters() == [] # No value yet means no parameters + + # set tag text + widget_item = widget.tree_widget.topLevelItem(0) + tag_widget: InputLineEdit = widget.tree_widget.itemWidget(widget_item, 0) + with qtbot.waitSignal(tag_widget.textChanged): + tag_widget.setText("motor") + assert widget.parameters() == ["motor"] + + # Remove tag + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + with qtbot.waitSignal(widget._button_remove.clicked): + qtbot.mouseClick(widget._button_remove, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 1 + + # Clear all tags + widget.clear_widget() + assert widget.tree_widget.topLevelItemCount() == 0 diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py new file mode 100644 index 000000000..e7326c537 --- /dev/null +++ b/tests/unit_tests/test_device_manager_view.py @@ -0,0 +1,838 @@ +"""Unit tests for the device manager view""" + +# pylint: disable=protected-access,redefined-outer-name + +from typing import Any +from unittest import mock + +import pytest +from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from qtpy import QtCore, QtWidgets + +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import ( + ConfigChoiceDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import ( + DeviceFormDialog, + DeviceManagerOphydValidationDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + DeviceStatusItem, + UploadRedisDialog, + ValidationSection, +) +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + CustomBusyWidget, + DeviceManagerDisplayWidget, +) +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( + DeviceManagerView, + DeviceManagerWidget, +) +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTable, + DMConfigView, + DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + OphydValidation, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def device_config() -> dict: + """Fixture for a sample device configuration.""" + return DeviceModel( + name="TestDevice", enabled=True, deviceClass="TestClass", readoutPriority="baseline" + ).model_dump() + + +class TestDeviceManagerViewDialogs: + """Test class for DeviceManagerView dialog interactions.""" + + @pytest.fixture + def mock_dm_view(self, qtbot, mocked_client): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerDisplayWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def config_choice_dialog(self, qtbot, mock_dm_view): + """Fixture for ConfigChoiceDialog.""" + try: + dialog = ConfigChoiceDialog(mock_dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_config_choice_dialog(self, mock_dm_view, config_choice_dialog, qtbot): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == mock_dm_view + + # Test dialog components + with (mock.patch.object(config_choice_dialog, "done") as mock_done,): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.REPLACE) + mock_done.reset_mock() + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.ADD) + mock_done.reset_mock() + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.CANCEL) + + @pytest.fixture + def device_manager_ophyd_test_dialog(self, qtbot): + """Fixture for DeviceManagerOphydValidationDialog.""" + dialog = DeviceManagerOphydValidationDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_manager_ophyd_test_dialog( + self, device_manager_ophyd_test_dialog: DeviceManagerOphydValidationDialog, qtbot + ): + """Test the DeviceManagerOphydValidationDialog.""" + dialog = device_manager_ophyd_test_dialog + assert dialog.text_box.toPlainText() == "" + + dialog._on_device_validated( + {"name": "TestDevice", "enabled": True}, + config_status=0, + connection_status=0, + validation_msg="All good", + ) + assert dialog.validation_result == ( + {"name": "TestDevice", "enabled": True}, + 0, + 0, + "All good", + ) + assert dialog.text_box.toPlainText() != "" + + @pytest.fixture + def device_form_dialog(self, qtbot): + """Fixture for DeviceFormDialog.""" + dialog = DeviceFormDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_form_dialog(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test the DeviceFormDialog.""" + # Initial state + dialog = device_form_dialog + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES) + + # Test select a group from available templates + variant_combo = dialog._control_widgets["variant_combo"] + assert variant_combo.isEnabled() is False + + with qtbot.waitSignal(group_combo.currentTextChanged): + epics_signal_index = group_combo.findText("EpicsSignal") + group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group + + assert variant_combo.count() == len(OPHYD_DEVICE_TEMPLATES["EpicsSignal"]) + assert variant_combo.isEnabled() is True + + # Check that numb of widgets in connection settings box is correct + fields_in_config = len( + OPHYD_DEVICE_TEMPLATES["EpicsSignal"].get(variant_combo.currentText(), {}) + ) # At this point this should be read_pv & write_pv + connection_settings_layout: QtWidgets.QGridLayout = ( + dialog._device_config_template.connection_settings_box.layout() + ) + assert ( + connection_settings_layout.count() == fields_in_config * 2 + ) # Each field has a label and a widget + + def test_device_form_dialog_help_methods( + self, device_form_dialog: DeviceFormDialog, device_config, qtbot + ): + """Test help methods in DeviceFormDialog.""" + # Test handle devices already in session results + dialog = device_form_dialog + + # Test _handle_devices_already_in_session_results + with mock.patch.object(dialog, "_handle_validation_result") as mock_handle_validation: + dialog._handle_devices_already_in_session_results([(device_config, 0, 0, "")]) + mock_handle_validation.assert_called_once_with(device_config, 0, 0, "") + mock_handle_validation.reset_mock() + dialog._handle_devices_already_in_session_results([]) + mock_handle_validation.assert_not_called() + mock_handle_validation.reset_mock() + dialog._handle_devices_already_in_session_results( + [(device_config, 1, 0, ""), (device_config, 0, 0, "")] + ) + mock_handle_validation.assert_called_once_with( + device_config, 1, 0, "" + ) # Should be called with first + + # Test _handle_validation_result + # I. No wait dialog present + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog._validation_result == (device_config, 1, 3, "All good") + + # II. No previous validation, but wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 3, "All good") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + + mock_wait_dialog.reset_mock() + assert dialog._wait_dialog is None + + # III. Previous validation present and the same config, wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + dialog._validation_result = (device_config, 1, 1, "Previous bad") + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 1, "Previous bad") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + + mock_wait_dialog.reset_mock() + assert dialog._wait_dialog is None + + # IV. Previous validation present but different config, wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + different_config = device_config.copy() + different_config["deviceClass"] = "DifferentClass" + dialog._validation_result = (different_config, 1, 1, "Previous bad") + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 3, "All good") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + + def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test setting device configuration in DeviceFormDialog.""" + dialog = device_form_dialog + sample_config = { + "name": "TestDevice", + "enabled": True, + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "baseline", + "deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"}, + } + DeviceModel.model_validate(sample_config) + dialog.set_device_config(sample_config) + + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.currentText() == "EpicsSignal" + variant_combo: QtWidgets.QComboBox = dialog._control_widgets["variant_combo"] + assert variant_combo.currentText() == "EpicsSignal" + config = dialog._device_config_template.get_config_fields() + assert config["name"] == "TestDevice" + assert config["deviceClass"] == "ophyd.EpicsSignal" + assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" + + # Test now to add the device config with different validation results + # For this we have to mock the additional ophyd_validation checks + with ( + mock.patch.object(dialog, "_create_validation_dialog") as mock_create_dialog, + mock.patch.object(dialog, "_create_and_run_ophyd_validation") as mock_create_validation, + ): + + # Set the validation results, assume that test was running + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.VALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + with qtbot.waitSignal(dialog.accepted_data) as sig_blocker: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + config, _, _, _, _ = sig_blocker.args + mock_warning_box.assert_not_called() + + mock_create_dialog.assert_called_once() + mock_create_validation.assert_called_once() + mock_create_dialog.reset_mock() + mock_create_validation.reset_mock() + + # Called with config_status invalid should show warning + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.INVALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once() + mock_create_dialog.assert_called_once() + mock_create_validation.assert_called_once() + mock_create_dialog.reset_mock() + mock_create_validation.reset_mock() + + # Set to random config without name + + random_config = {"deviceClass": "Unknown"} + dialog.set_device_config(random_config) + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + 0, + 0, + "", + ) + assert group_combo.currentText() == "CustomDevice" + assert variant_combo.currentText() == "CustomDevice" + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once_with( + "Invalid Device Name", + f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r}", + ) + mock_create_dialog.assert_not_called() + mock_create_validation.assert_not_called() + + def test_device_status_item(self, device_config: dict, qtbot): + """Test the DeviceStatusItem widget.""" + item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0) + qtbot.addWidget(item) + qtbot.waitExposed(item) + assert item.device_config == device_config + assert item.device_name == device_config.get("name", "") + assert item.config_status == 0 + assert item.connection_status == 0 + assert "config_status" in item.icons + assert "connection_status" in item.icons + + # Update status + item.update_status(config_status=1, connection_status=2) + assert item.config_status == 1 + assert item.connection_status == 2 + + def test_validation_section(self, device_config: dict, qtbot): + """Test the validation section.""" + device_config_2 = device_config.copy() + device_config_2["name"] = "device_2" + + # Create section + section = ValidationSection(title="Validation Results") + qtbot.addWidget(section) + qtbot.waitExposed(section) + assert section.title() == "Validation Results" + initial_widget_in_container = section.table.rowCount() + + # Add widgets + section.add_device(device_config=device_config, config_status=0, connection_status=0) + assert initial_widget_in_container + 1 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config["name"]) == section.table.rowCount() - 1 + + # Add another device + section.add_device(device_config=device_config_2, config_status=1, connection_status=1) + assert initial_widget_in_container + 2 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config_2["name"]) == section.table.rowCount() - 1 + + # Clear devices + section.clear_devices() + assert section.table.rowCount() == 0 + + # Update test summary label + section.update_summary("2 devices validated, 1 failed.") + assert section.summary_label.text() == "2 devices validated, 1 failed." + + @pytest.fixture + def device_configs_valid(self, device_config: dict): + """Fixture for multiple device configurations.""" + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.VALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_invalid(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.INVALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_unknown(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.UNKNOWN.value, i) + return return_dict + + @pytest.fixture + def upload_redis_dialog(self, qtbot): + """Fixture for UploadRedisDialog.""" + dialog = UploadRedisDialog(parent=None, device_configs={}) + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_upload_redis_valid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_valid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_valid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_unknown_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_unknown, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_unknown + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_invalid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_invalid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_invalid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + +class TestDeviceManagerView: + """Test class for DeviceManagerView functionality.""" + + @pytest.fixture + def dm_view(self, qtbot, mocked_client): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + # Assign the mocked client + widget.device_manager_widget.client = mocked_client + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_dm_view_initialization(self, dm_view, qtbot): + """Test DeviceManagerView initialization.""" + assert isinstance(dm_view.device_manager_widget, DeviceManagerWidget) + # If on_enter is called, overlay should be shown initially + dm_widget = dm_view.device_manager_widget + dm_view.on_enter() + assert dm_widget.stacked_layout.currentWidget() == dm_widget._overlay_widget + + with mock.patch.object(dm_widget.device_manager_display, "_load_file_action") as mock_load: + # Simulate clicking "Load Config From File" button + with qtbot.waitSignal(dm_widget.button_load_config_from_file.clicked): + qtbot.mouseClick(dm_widget.button_load_config_from_file, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + + # Reset for test loading current config + dm_widget._initialized = False + dm_widget.stacked_layout.setCurrentWidget(dm_widget._overlay_widget) + + with mock.patch.object( + dm_widget.client.device_manager, "_get_redis_device_config" + ) as mock_get: + mock_get.return_value = [] + # Simulate clicking "Load Current Config" button + with mock.patch.object( + dm_widget.device_manager_display.device_table_view, "set_device_config" + ) as mock_set: + with qtbot.waitSignal(dm_widget.button_load_current_config.clicked): + qtbot.mouseClick(dm_widget.button_load_current_config, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert ( + dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + ) + mock_set.assert_called_once_with([]) + + @pytest.fixture + def device_manager_display_widget(self, qtbot, mocked_client): + """Fixture for DeviceManagerDisplayWidget within DeviceManagerView. + We will patch the OphydValidation _thread_pool_poll_loop to avoid starting threads during tests, + and the _is_device_in_redis_session method to avoid Redis dependencies + """ + + with ( + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop", + return_value=None, + ), + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session", + return_value=False, + ), + ): + widget = DeviceManagerDisplayWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def custom_busy(self, qtbot, mocked_client): + """Fixture for the custom busy widget of the DeviceManagerDisplayWidget.""" + widget = CustomBusyWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def device_configs(self, device_config: dict): + """Fixture for multiple device configurations.""" + cfg_iter = [] + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + cfg_iter.append(dev_config_copy) + return cfg_iter + + def test_custom_busy_widget(self, custom_busy: CustomBusyWidget, qtbot): + """Test the CustomBusyWidget functionality.""" + + # Check layout + assert custom_busy.progress is not None + assert custom_busy.spinner is not None + assert custom_busy.spinner._started is False + + # Check background + color = get_accent_colors() + bg = color._colors["BG"] + sheet = custom_busy.styleSheet() + assert bg.name() in sheet + assert "border-radius: 12px" in sheet + + # Show event should start spinner + custom_busy.showEvent(None) + assert custom_busy.spinner._started is True + + with qtbot.waitSignal(custom_busy.cancel_requested) as sig_blocker: + qtbot.mouseClick(custom_busy.cancel_button, QtCore.Qt.LeftButton) + # Check that the signal was emitted + assert sig_blocker.signal_triggered is True + + # Hide should + custom_busy.hideEvent(None) + assert custom_busy.spinner._started is False + + def test_device_manager_view_add_remove_device( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config + ): + """Test adding a device via the DeviceManagerView.""" + dm_view = device_manager_display_widget + dm_view._add_to_table_from_dialog( + device_config, config_status=0, connection_status=0, msg="" + ) + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [device_config] + + # Remove the device + dm_view.device_table_view.table.selectRow(0) + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [] + + def test_dock_widgets_exist(self, device_manager_display_widget: DeviceManagerDisplayWidget): + """Test that all required dock widgets are created.""" + dm_view = device_manager_display_widget + dock_widgets = dm_view.dock_manager.dockWidgets() + + # Check that we have the expected number of dock widgets + assert len(dock_widgets) == 4 + + # Check for specific widget types + widget_types = [dock.widget().__class__ for dock in dock_widgets] + + # OphydValidation is used in a layout with a QWidget + assert DMConfigView in widget_types + assert DocstringView in widget_types + assert DeviceTable in widget_types + + def test_toolbar_initialization( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): + """Test that the toolbar is properly initialized with expected bundles.""" + dm_view = device_manager_display_widget + assert dm_view.toolbar is not None + assert "IO" in dm_view.toolbar.bundles + assert "Table" in dm_view.toolbar.bundles + + def test_io_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): + """Test that IO bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget + assert "IO" in dm_view.toolbar.bundles + io_actions = ["load", "save_to_disk", "flush_redis", "load_redis", "update_config_redis"] + for action in io_actions: + assert dm_view.toolbar.components.exists(action) + + def test_load_file_action_triggered( + self, tmp_path, device_manager_display_widget: DeviceManagerDisplayWidget + ): + """Test load file action trigger mechanism.""" + dm_view = device_manager_display_widget + with ( + mock.patch.object(dm_view, "_get_config_base_path", return_value=tmp_path), + mock.patch.object( + dm_view, "_get_file_path", return_value=str(tmp_path) + ) as mock_get_file, + mock.patch.object(dm_view, "_load_config_from_file") as mock_load_config, + ): + # Setup dialog mock + dm_view.toolbar.components._components["load"].action.action.triggered.emit() + mock_get_file.assert_called_once_with(str(tmp_path), "open_file") + mock_load_config.assert_called_once_with(str(tmp_path)) + + def test_table_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): + """Test that Table bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget + assert "Table" in dm_view.toolbar.bundles + table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] + for action in table_actions: + assert dm_view.toolbar.components.exists(action) + + @mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_display_widget._yes_no_question" + ) + def test_reset_composed_view( + self, mock_question, device_manager_display_widget: DeviceManagerDisplayWidget + ): + """Test reset composed view when user confirms.""" + dm_view = device_manager_display_widget + with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.Yes + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_called_once() + mock_clear.reset_mock() + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.No + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_not_called() + + def test_add_device_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): + """Test add device action opens dialog correctly.""" + dm_view = device_manager_display_widget + with mock.patch.object(dm_view, "_add_device_action") as mock_add: + dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() + mock_add.assert_called_once() + + def test_run_validate_connection_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict, qtbot + ): + """Test run validate connection action is connected.""" + dm_view = device_manager_display_widget + + with mock.patch.object( + dm_view.ophyd_test_view, "change_device_configs" + ) as mock_change_configs: + # First, add device configs to the table + with qtbot.waitSignal(dm_view.device_table_view.device_configs_changed) as sig_blocker: + dm_view.device_table_view.add_device_configs(device_configs) + cfgs, added, skip_validation = sig_blocker.args + assert cfgs == device_configs + assert added is True + assert skip_validation is False + mock_change_configs.assert_called_once_with( + device_configs=device_configs, added=True, skip_validation=False + ) # Configs were added + mock_change_configs.reset_mock() + + # Trigger the validate connection action without selection, should validate all + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + assert len(mock_change_configs.call_args[0][0]) == len(device_configs) + mock_change_configs.assert_called_once_with( + device_configs, True, True + ) # Configs were added with connect=True + mock_change_configs.reset_mock() + + # Select a single row and trigger again, should only validate that one + dm_view.device_table_view.table.selectRow(0) + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + assert len(mock_change_configs.call_args[0][0]) == 1 + + def test_handle_cancel_config_upload_failed( + self, device_manager_display_widget: DeviceManagerDisplayWidget, qtbot + ): + """Test handling cancel during config upload failure.""" + dm_view = device_manager_display_widget + validation_results = { + "Device_1": ( + {"name": "Device_1"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + "Device_2": ( + {"name": "Device_2"}, + ConfigStatus.INVALID.value, + ConnectionStatus.UNKNOWN.value, + ), + } + with mock.patch.object( + dm_view.device_table_view, "get_validation_results", return_value=validation_results + ): + with ( + mock.patch.object( + dm_view.device_table_view, "update_multiple_device_validations" + ) as mock_update, + mock.patch.object( + dm_view.ophyd_test_view, "change_device_configs" + ) as mock_change_configs, + ): + with qtbot.waitSignal( + dm_view.device_table_view.device_config_in_sync_with_redis + ) as sig_blocker: + dm_view._handle_cancel_config_upload_failed( + exception=Exception("Test Exception") + ) + assert sig_blocker.signal_triggered is True + mock_change_configs.assert_called_once_with( + [validation_results["Device_1"][0], validation_results["Device_2"][0]], + added=True, + skip_validation=False, + ) + mock_update.assert_called_once_with( + [ + ( + validation_results["Device_1"][0], + validation_results["Device_1"][1], + ConnectionStatus.UNKNOWN.value, + "Upload Cancelled", + ), + ( + validation_results["Device_2"][0], + validation_results["Device_2"][1], + ConnectionStatus.UNKNOWN.value, + "Upload Cancelled", + ), + ] + ) diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index 65bffd238..78329344f 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -4,7 +4,6 @@ from bec_lib.device import Signal from qtpy.QtWidgets import QWidget -from bec_widgets.tests.utils import FakeDevice from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( @@ -153,3 +152,251 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client): widget.deleteLater() mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register) + + +def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox): + """Test get_signal_name returns obj_name from item data when available.""" + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.set_device("samx") + + # Select a signal that has item data with obj_name + device_signal_combobox.setCurrentText("samx (readback)") + + # get_signal_name should return the obj_name from item data + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "samx" + + +def test_signal_combobox_get_signal_name_without_item_data(qtbot, device_signal_combobox): + """Test get_signal_name returns currentText when no item data available.""" + # Add a custom item without item data + device_signal_combobox.addItem("custom_signal") + device_signal_combobox.setCurrentText("custom_signal") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "custom_signal" + + +def test_signal_combobox_get_signal_name_not_found(qtbot, device_signal_combobox): + """Test get_signal_name when text is not found in combobox (index == -1).""" + # Set editable to allow text that's not in items + device_signal_combobox.setEditable(True) + device_signal_combobox.setCurrentText("nonexistent_signal") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "nonexistent_signal" + + +def test_signal_combobox_get_signal_name_empty(qtbot, device_signal_combobox): + """Test get_signal_name when combobox is empty.""" + device_signal_combobox.clear() + device_signal_combobox.setEditable(True) + device_signal_combobox.setCurrentText("") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "" + + +def test_signal_combobox_get_signal_name_with_velocity(qtbot, device_signal_combobox): + """Test get_signal_name with velocity signal.""" + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.include_config_signals = True + device_signal_combobox.set_device("samx") + + # Select velocity signal + device_signal_combobox.setCurrentText("velocity") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "samx_velocity" + + +def test_signal_combobox_get_signal_config(device_signal_combobox): + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.set_device("samx") + + index = device_signal_combobox.currentIndex() + assert index != -1 + + expected_config = device_signal_combobox.itemData(index) + assert expected_config is not None + assert device_signal_combobox.get_signal_config() == expected_config + + +def test_signal_combobox_get_signal_config_disabled(qtbot, mocked_client): + combobox = create_widget( + qtbot=qtbot, widget=SignalComboBox, client=mocked_client, store_signal_config=False + ) + combobox.include_normal_signals = True + combobox.include_hinted_signals = True + combobox.set_device("samx") + assert combobox.get_signal_config() is None + + +def test_signal_combobox_signal_class_filter_by_device(qtbot, mocked_client): + """Test signal_class_filter restricts signals to the selected device.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ("samx", "samx_readback_async", {"obj_name": "samx_readback_async"}), + ("samy", "samy_readback_async", {"obj_name": "samy_readback_async"}), + ("bpm4i", "bpm4i_value_async", {"obj_name": "bpm4i_value_async"}), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + + assert widget.signals == ["samx_readback_async"] + assert widget.signal_class_filter == ["AsyncSignal"] + + widget.set_device("samy") + assert widget.signals == ["samy_readback_async"] + + +def test_signal_class_filter_setter_clears_to_kind_filters(qtbot, mocked_client): + """Clearing signal_class_filter should rebuild list using Kind filters.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + assert widget.signals == ["samx_readback_async"] + + widget.signal_class_filter = [] + samx = widget.dev.samx + assert widget.signals == [ + ("samx (readback)", samx._info["signals"].get("readback")), + ("setpoint", samx._info["signals"].get("setpoint")), + ("velocity", samx._info["signals"].get("velocity")), + ] + + +def test_signal_class_filter_setter_none_reverts_to_kind_filters(qtbot, mocked_client): + """Setting signal_class_filter to None should revert to Kind-based filtering.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + assert widget.signals == ["samx_readback_async"] + + widget.signal_class_filter = None + samx = widget.dev.samx + assert widget.signals == [ + ("samx (readback)", samx._info["signals"].get("readback")), + ("setpoint", samx._info["signals"].get("setpoint")), + ("velocity", samx._info["signals"].get("velocity")), + ] + + +def test_signal_combobox_set_first_element_as_empty(qtbot, mocked_client): + """set_first_element_as_empty should insert/remove the empty option.""" + widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client) + widget.addItem("item1") + widget.addItem("item2") + + widget.set_first_element_as_empty = True + assert widget.itemText(0) == "" + + widget.set_first_element_as_empty = False + assert widget.itemText(0) == "item1" + + +def test_signal_combobox_class_kind_ndim_filters(qtbot, mocked_client): + """Test class + kind + ndim filters are all applied together.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ( + "samx", + "sig1", + { + "obj_name": "samx_sig1", + "kind_str": "hinted", + "describe": {"signal_info": {"ndim": 1}}, + }, + ), + ( + "samx", + "sig2", + { + "obj_name": "samx_sig2", + "kind_str": "config", + "describe": {"signal_info": {"ndim": 2}}, + }, + ), + ( + "samy", + "sig3", + { + "obj_name": "samy_sig3", + "kind_str": "normal", + "describe": {"signal_info": {"ndim": 1}}, + }, + ), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + ndim_filter=1, + device="samx", + ) + + # Default kinds are hinted + normal, ndim=1, device=samx + assert widget.signals == ["sig1"] + + # Enable config kinds and widen ndim to include sig2 + widget.include_config_signals = True + widget.ndim_filter = 2 + assert widget.signals == ["sig2"] + + +def test_signal_combobox_require_device_validation(qtbot, mocked_client): + """Require device should block validation and list updates without a device.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ( + "samx", + "sig1", + { + "obj_name": "samx_sig1", + "kind_str": "hinted", + "describe": {"signal_info": {"ndim": 1}}, + }, + ) + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + require_device=True, + ) + + assert widget.signals == [] + widget.set_device("samx") + assert widget.signals == ["sig1"] + + resets: list[str] = [] + widget.signal_reset.connect(lambda: resets.append("reset")) + widget.check_validity("") + assert resets == ["reset"] diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py new file mode 100644 index 000000000..8b6a309da --- /dev/null +++ b/tests/unit_tests/test_dock_area.py @@ -0,0 +1,2324 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import base64 +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest +from qtpy.QtCore import QSettings, Qt, QTimer +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QDialog, QMessageBox, QWidget + +import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module +import bec_widgets.widgets.containers.dock_area.profile_utils as profile_utils +from bec_widgets.widgets.containers.dock_area.basic_dock_area import ( + DockAreaWidget, + DockSettingsDialog, +) +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + SETTINGS_KEYS, + default_profile_path, + get_profile_info, + is_profile_read_only, + is_quick_select, + list_profiles, + load_default_profile_screenshot, + load_user_profile_screenshot, + open_default_settings, + open_user_settings, + read_manifest, + restore_user_from_default, + set_quick_select, + user_profile_path, + write_manifest, +) +from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( + PreviewPanel, + RestoreProfileDialog, +) +from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager + +from .client_mocks import mocked_client + + +@pytest.fixture +def advanced_dock_area(qtbot, mocked_client): + """Create an AdvancedDockArea instance for testing.""" + widget = BECDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + """Ensure each test writes profiles into a unique temporary directory.""" + root = tmp_path / "profiles_root" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + yield + + +@pytest.fixture +def temp_profile_dir(): + """Return the current temporary profile directory.""" + return os.environ["BECWIDGETS_PROFILE_DIR"] + + +@pytest.fixture +def module_profile_factory(monkeypatch, tmp_path): + """Provide a helper to create synthetic module-level (read-only) profiles.""" + module_dir = tmp_path / "module_profiles" + module_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(module_dir)) + monkeypatch.setattr(profile_utils, "plugin_profiles_dir", lambda: None) + + def _create(name="readonly_profile", content="[profile]\n"): + path = module_dir / f"{name}.ini" + path.write_text(content) + return name + + return _create + + +@pytest.fixture +def workspace_manager_target(): + class _Signal: + def __init__(self): + self._slot = None + + def connect(self, slot): + self._slot = slot + + def emit(self, value): + if self._slot: + self._slot(value) + + class _Combo: + def __init__(self): + self.current_text = "" + + def setCurrentText(self, text): + self.current_text = text + + class _Action: + def __init__(self, widget): + self.widget = widget + + class _Components: + def __init__(self, combo): + self._combo = combo + + def get_action(self, name): + return _Action(self._combo) + + class _Toolbar: + def __init__(self, combo): + self.components = _Components(combo) + + class _Target: + def __init__(self): + self.profile_changed = _Signal() + self._combo = _Combo() + self.toolbar = _Toolbar(self._combo) + self._current_profile_name = None + self.load_profile_calls = [] + self.save_called = False + self.refresh_calls = 0 + + def load_profile(self, name): + self.load_profile_calls.append(name) + self._current_profile_name = name + + def save_profile(self): + self.save_called = True + + def save_profile_dialog(self, name: str | None = None): + """Mock save_profile_dialog that sets save_called flag.""" + self.save_called = True + + def _refresh_workspace_list(self): + self.refresh_calls += 1 + + def delete_profile(self, name: str, show_dialog: bool = False) -> bool: + """Mock delete_profile that performs actual file deletion.""" + from qtpy.QtWidgets import QMessageBox + + from bec_widgets.widgets.containers.dock_area.profile_utils import ( + delete_profile_files, + is_profile_read_only, + ) + + if is_profile_read_only(name): + if show_dialog: + QMessageBox.information( + None, + "Delete Profile", + f"Profile '{name}' is read-only and cannot be deleted.", + ) + return False + raise ValueError(f"Profile '{name}' is read-only.") + delete_profile_files(name) + if self._current_profile_name == name: + self._current_profile_name = None + self._refresh_workspace_list() + return True + + def _factory(): + return _Target() + + return _factory + + +@pytest.fixture +def basic_dock_area(qtbot, mocked_client): + """Create a namesake DockAreaWidget without the advanced toolbar.""" + widget = DockAreaWidget(client=mocked_client, title="Test Dock Area") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class _NamespaceProfiles: + """Helper that routes profile file helpers through a namespace.""" + + def __init__(self, widget: BECDockArea): + self.namespace = widget.profile_namespace + + def open_user(self, name: str): + return open_user_settings(name, namespace=self.namespace) + + def open_default(self, name: str): + return open_default_settings(name, namespace=self.namespace) + + def user_path(self, name: str) -> str: + return user_profile_path(name, namespace=self.namespace) + + def default_path(self, name: str) -> str: + return default_profile_path(name, namespace=self.namespace) + + def list_profiles(self) -> list[str]: + return list_profiles(namespace=self.namespace) + + def set_quick_select(self, name: str, enabled: bool): + set_quick_select(name, enabled, namespace=self.namespace) + + def is_quick_select(self, name: str) -> bool: + return is_quick_select(name, namespace=self.namespace) + + +def profile_helper(widget: BECDockArea) -> _NamespaceProfiles: + """Return a helper wired to the widget's profile namespace.""" + return _NamespaceProfiles(widget) + + +class TestBasicDockArea: + """Focused coverage for the lightweight DockAreaWidget base.""" + + def test_new_widget_instance_registers_in_maps(self, basic_dock_area): + panel = QWidget(parent=basic_dock_area) + panel.setObjectName("basic_panel") + + dock = basic_dock_area.new(panel, return_dock=True) + + assert dock.objectName() == "basic_panel" + assert basic_dock_area.dock_map()["basic_panel"] is dock + assert basic_dock_area.widget_map()["basic_panel"] is panel + + def test_new_widget_string_creates_widget(self, basic_dock_area, qtbot): + basic_dock_area.new("DarkModeButton") + qtbot.waitUntil(lambda: len(basic_dock_area.dock_list()) > 0, timeout=1000) + + assert basic_dock_area.widget_list() + + def test_custom_close_handler_invoked(self, basic_dock_area, qtbot): + class CloseAwareWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("closable") + self.closed = False + + def handle_dock_close(self, dock, widget): # pragma: no cover - exercised via signal + self.closed = True + dock.closeDockWidget() + dock.deleteDockWidget() + + widget = CloseAwareWidget(parent=basic_dock_area) + dock = basic_dock_area.new(widget, return_dock=True) + + dock.closeRequested.emit() + qtbot.waitUntil(lambda: widget.closed, timeout=1000) + + assert widget.closed is True + assert "closable" not in basic_dock_area.dock_map() + + def test_attach_all_and_delete_all(self, basic_dock_area): + first = QWidget(parent=basic_dock_area) + first.setObjectName("floating_one") + second = QWidget(parent=basic_dock_area) + second.setObjectName("floating_two") + + dock_one = basic_dock_area.new(first, return_dock=True, start_floating=True) + dock_two = basic_dock_area.new(second, return_dock=True, start_floating=True) + assert dock_one.isFloating() and dock_two.isFloating() + + basic_dock_area.attach_all() + + assert not dock_one.isFloating() + assert not dock_two.isFloating() + + basic_dock_area.delete_all() + assert basic_dock_area.dock_list() == [] + + def test_remove_widget_by_object_name(self, basic_dock_area, qtbot): + """Test remove_widget removes widget by object name.""" + panel_one = QWidget(parent=basic_dock_area) + panel_one.setObjectName("panel_one") + panel_two = QWidget(parent=basic_dock_area) + panel_two.setObjectName("panel_two") + + basic_dock_area.new(panel_one, return_dock=True) + basic_dock_area.new(panel_two, return_dock=True) + + assert len(basic_dock_area.dock_list()) == 2 + assert "panel_one" in basic_dock_area.dock_map() + assert "panel_two" in basic_dock_area.dock_map() + + # Remove panel_one + result = basic_dock_area.delete("panel_one") + qtbot.wait(100) + + assert result is True + assert len(basic_dock_area.dock_list()) == 1 + assert "panel_one" not in basic_dock_area.dock_map() + assert "panel_two" in basic_dock_area.dock_map() + + def test_manifest_serialization_includes_floating_geometry( + self, basic_dock_area, qtbot, tmp_path + ): + anchored = QWidget(parent=basic_dock_area) + anchored.setObjectName("anchored_widget") + floating = QWidget(parent=basic_dock_area) + floating.setObjectName("floating_widget") + + basic_dock_area.new(anchored, return_dock=True) + dock_floating = basic_dock_area.new(floating, return_dock=True, start_floating=True) + qtbot.waitUntil(lambda: dock_floating.isFloating(), timeout=2000) + + settings_path = tmp_path / "manifest.ini" + settings = QSettings(str(settings_path), QSettings.IniFormat) + write_manifest(settings, basic_dock_area.dock_list()) + settings.sync() + + manifest_entries = read_manifest(settings) + assert len(manifest_entries) == 2 + assert manifest_entries[0]["object_name"] == "floating_widget" + assert manifest_entries[0]["floating"] is True + assert manifest_entries[0]["floating_relative"] is not None + assert manifest_entries[1]["object_name"] == "anchored_widget" + assert manifest_entries[1]["floating"] is False + + def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area): + weights = {"default": 0.5, "left": 2, "center": 3, "right": 4} + + result = basic_dock_area._coerce_weights(weights, 3, Qt.Orientation.Horizontal) + + assert result == [2.0, 3.0, 4.0] + assert basic_dock_area._coerce_weights([0.0], 3, Qt.Orientation.Vertical) == [0.0, 1.0, 1.0] + assert basic_dock_area._coerce_weights([0.0, 0.0], 2, Qt.Orientation.Vertical) == [1.0, 1.0] + + def test_splitter_override_keys_are_normalized(self, basic_dock_area): + overrides = {0: [1, 2], (1, 0): [3, 4], "2.1": [5], " / ": [6]} + + normalized = basic_dock_area._normalize_override_keys(overrides) + + assert normalized == {(0,): [1, 2], (1, 0): [3, 4], (2, 1): [5], (): [6]} + + def test_schedule_splitter_weights_sets_sizes(self, basic_dock_area, monkeypatch): + monkeypatch.setattr(QTimer, "singleShot", lambda *_args: _args[-1]()) + + class DummySplitter: + def __init__(self): + self._children = [object(), object(), object()] + self.sizes = None + self.stretch = [] + + def count(self): + return len(self._children) + + def orientation(self): + return Qt.Orientation.Horizontal + + def width(self): + return 300 + + def height(self): + return 120 + + def setSizes(self, sizes): + self.sizes = sizes + + def setStretchFactor(self, idx, value): + self.stretch.append((idx, value)) + + splitter = DummySplitter() + + basic_dock_area._schedule_splitter_weights(splitter, [1, 2, 1]) + + assert splitter.sizes == [75, 150, 75] + assert splitter.stretch == [(0, 100), (1, 200), (2, 100)] + + def test_apply_splitter_tree_honors_overrides(self, basic_dock_area, monkeypatch): + class DummySplitter: + def __init__(self, orientation, children=None, label="splitter"): + self._orientation = orientation + self._children = list(children or []) + self.label = label + + def count(self): + return len(self._children) + + def orientation(self): + return self._orientation + + def widget(self, idx): + return self._children[idx] + + monkeypatch.setattr(basic_dock_module.QtAds, "CDockSplitter", DummySplitter) + + leaf = DummySplitter(Qt.Orientation.Horizontal, [], label="leaf") + column_one = DummySplitter(Qt.Orientation.Vertical, [leaf], label="column_one") + column_zero = DummySplitter(Qt.Orientation.Vertical, [], label="column_zero") + root = DummySplitter(Qt.Orientation.Horizontal, [column_zero, column_one], label="root") + + calls = [] + + def fake_schedule(self, splitter, weights): + calls.append((splitter.label, weights)) + + monkeypatch.setattr(DockAreaWidget, "_schedule_splitter_weights", fake_schedule) + + overrides = {(): ["root_override"], (0,): ["column_override"]} + + basic_dock_area._apply_splitter_tree( + root, (), horizontal=[1, 2], vertical=[3, 4], overrides=overrides + ) + + assert calls[0] == ("root", ["root_override"]) + assert calls[1] == ("column_zero", ["column_override"]) + assert calls[2] == ("column_one", [3, 4]) + assert calls[3] == ("leaf", ["column_override"]) + + def test_set_layout_ratios_normalizes_and_applies(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + root_one = object() + root_two = object() + containers = [DummyContainer(root_one), DummyContainer(None), DummyContainer(root_two)] + + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def fake_apply(self, splitter, path, horizontal, vertical, overrides): + calls.append((splitter, path, horizontal, vertical, overrides)) + + monkeypatch.setattr(DockAreaWidget, "_apply_splitter_tree", fake_apply) + + basic_dock_area.set_layout_ratios( + horizontal=[1, 1, 1], vertical=[2, 3], splitter_overrides={"1/0": [5, 5], "": [9]} + ) + + assert len(calls) == 2 + for splitter, path, horizontal, vertical, overrides in calls: + assert splitter in {root_one, root_two} + assert path == () + assert horizontal == [1, 1, 1] + assert vertical == [2, 3] + assert overrides == {(): [9], (1, 0): [5, 5]} + + def test_show_settings_action_defaults_disabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_default") + + dock = basic_dock_area.new(widget, return_dock=True) + + assert dock._dock_preferences.get("show_settings_action") is False + assert not hasattr(dock, "setting_action") + + def test_show_settings_action_can_be_enabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_enabled") + + dock = basic_dock_area.new(widget, return_dock=True, show_settings_action=True) + + assert dock._dock_preferences.get("show_settings_action") is True + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + + def test_collect_splitter_info_describes_children(self, basic_dock_area, monkeypatch): + class DummyDockWidget: + def __init__(self, name): + self._name = name + + def objectName(self): + return self._name + + class DummyDockArea: + def __init__(self, dock_names): + self._docks = [DummyDockWidget(name) for name in dock_names] + + def dockWidgets(self): + return self._docks + + class DummySplitter: + def __init__(self, orientation, children=None): + self._orientation = orientation + self._children = list(children or []) + + def orientation(self): + return self._orientation + + def count(self): + return len(self._children) + + def widget(self, idx): + return self._children[idx] + + class Spacer: + pass + + monkeypatch.setattr(basic_dock_module, "CDockSplitter", DummySplitter) + monkeypatch.setattr(basic_dock_module, "CDockAreaWidget", DummyDockArea) + monkeypatch.setattr(basic_dock_module, "CDockWidget", DummyDockWidget) + + nested_splitter = DummySplitter(Qt.Orientation.Horizontal) + dock_area_child = DummyDockArea(["left", "right"]) + dock_child = DummyDockWidget("solo") + spacer = Spacer() + root_splitter = DummySplitter( + Qt.Orientation.Vertical, [nested_splitter, dock_area_child, dock_child, spacer] + ) + + results = [] + + basic_dock_area._collect_splitter_info(root_splitter, (2,), results, container_index=5) + + assert len(results) == 2 + root_entry = results[0] + assert root_entry["container"] == 5 + assert root_entry["path"] == (2,) + assert root_entry["orientation"] == "vertical" + assert root_entry["children"] == [ + {"index": 0, "type": "splitter"}, + {"index": 1, "type": "dock_area", "docks": ["left", "right"]}, + {"index": 2, "type": "dock", "name": "solo"}, + {"index": 3, "type": "Spacer"}, + ] + nested_entry = results[1] + assert nested_entry["path"] == (2, 0) + assert nested_entry["orientation"] == "horizontal" + + def test_describe_layout_aggregates_containers(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + containers = [DummyContainer("root0"), DummyContainer(None), DummyContainer("root2")] + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def recorder(self, splitter, path, results, container_index): + entry = {"container": container_index, "splitter": splitter, "path": path} + results.append(entry) + calls.append(entry) + + monkeypatch.setattr(DockAreaWidget, "_collect_splitter_info", recorder) + + info = basic_dock_area.describe_layout() + + assert info == calls + assert [entry["splitter"] for entry in info] == ["root0", "root2"] + assert [entry["container"] for entry in info] == [0, 2] + assert all(entry["path"] == () for entry in info) + + def test_print_layout_structure_formats_output(self, basic_dock_area, monkeypatch, capsys): + entries = [ + { + "container": 1, + "path": (0,), + "orientation": "horizontal", + "children": [ + {"index": 0, "type": "dock_area", "docks": ["alpha", "beta"]}, + {"index": 1, "type": "dock", "name": "solo"}, + {"index": 2, "type": "splitter"}, + {"index": 3, "type": "Placeholder"}, + ], + } + ] + + monkeypatch.setattr(DockAreaWidget, "describe_layout", lambda self: entries) + + basic_dock_area.print_layout_structure() + + captured = capsys.readouterr().out.strip().splitlines() + assert captured == [ + "container=1 path=(0,) orientation=horizontal -> " + "[0:dock_area[alpha, beta], 1:dock(solo), 2:splitter, 3:Placeholder]" + ] + + +class TestAdvancedDockAreaInit: + """Test initialization and basic properties.""" + + def test_init(self, advanced_dock_area): + assert advanced_dock_area is not None + assert isinstance(advanced_dock_area, BECDockArea) + assert advanced_dock_area.mode == "creator" + assert hasattr(advanced_dock_area, "dock_manager") + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "dark_mode_button") + assert hasattr(advanced_dock_area, "state_manager") + + def test_rpc_and_plugin_flags(self): + assert BECDockArea.RPC is True + assert BECDockArea.PLUGIN is False + + def test_user_access_list(self): + expected_methods = [ + "new", + "widget_map", + "widget_list", + "workspace_is_locked", + "attach_all", + "delete_all", + ] + for method in expected_methods: + assert method in BECDockArea.USER_ACCESS + + +class TestDockManagement: + """Test dock creation, management, and manipulation.""" + + def test_new_widget_string(self, advanced_dock_area, qtbot): + """Test creating a new widget from string.""" + initial_count = len(advanced_dock_area.dock_list()) + + # Create a widget by string name + widget = advanced_dock_area.new("DarkModeButton") + + # Wait for the dock to be created (since it's async) + qtbot.wait(200) + + # Check that dock was actually created + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + # Check widget was returned + assert widget is not None + assert hasattr(widget, "name_established") + + def test_new_widget_instance(self, advanced_dock_area, qtbot): + """Test creating dock with existing widget instance.""" + from bec_widgets.widgets.plots.waveform.waveform import Waveform + + initial_count = len(advanced_dock_area.dock_list()) + + # Create widget instance + widget_instance = Waveform(parent=advanced_dock_area, client=advanced_dock_area.client) + widget_instance.setObjectName("test_widget") + + # Add it to dock area + result = advanced_dock_area.new(widget_instance) + + # Should return the same instance + assert result == widget_instance + + qtbot.wait(200) + + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + def test_dock_map(self, advanced_dock_area, qtbot): + """Test dock_map returns correct mapping.""" + # Initially empty + dock_map = advanced_dock_area.dock_map() + assert isinstance(dock_map, dict) + initial_count = len(dock_map) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock map updated + new_dock_map = advanced_dock_area.dock_map() + assert len(new_dock_map) == initial_count + 1 + + def test_dock_list(self, advanced_dock_area, qtbot): + """Test dock_list returns list of docks.""" + dock_list = advanced_dock_area.dock_list() + assert isinstance(dock_list, list) + initial_count = len(dock_list) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock list updated + new_dock_list = advanced_dock_area.dock_list() + assert len(new_dock_list) == initial_count + 1 + + def test_widget_map(self, advanced_dock_area, qtbot): + """Test widget_map returns widget mapping.""" + widget_map = advanced_dock_area.widget_map() + assert isinstance(widget_map, dict) + initial_count = len(widget_map) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget map updated + new_widget_map = advanced_dock_area.widget_map() + assert len(new_widget_map) == initial_count + 1 + + def test_widget_list(self, advanced_dock_area, qtbot): + """Test widget_list returns list of widgets.""" + widget_list = advanced_dock_area.widget_list() + assert isinstance(widget_list, list) + initial_count = len(widget_list) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget list updated + new_widget_list = advanced_dock_area.widget_list() + assert len(new_widget_list) == initial_count + 1 + + def test_delete_all(self, advanced_dock_area, qtbot): + """Test delete_all functionality.""" + # Create multiple widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(200) + + initial_count = len(advanced_dock_area.dock_list()) + assert initial_count >= 2 + + # Delete all + advanced_dock_area.delete_all() + + # Wait for deletion to complete + qtbot.wait(200) + + # Should have no docks + assert len(advanced_dock_area.dock_list()) == 0 + + +class TestAdvancedDockSettingsAction: + """Ensure AdvancedDockArea exposes dock settings actions by default.""" + + def test_settings_action_installed_by_default(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_default_settings") + + dock = advanced_dock_area.new(widget, return_dock=True) + + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + assert dock._dock_preferences.get("show_settings_action") is True + + def test_settings_action_can_be_disabled(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_settings_off") + + dock = advanced_dock_area.new(widget, return_dock=True, show_settings_action=False) + + assert not hasattr(dock, "setting_action") + assert dock._dock_preferences.get("show_settings_action") is False + + +class TestWorkspaceLocking: + """Test workspace locking functionality.""" + + def test_lock_workspace_property_getter(self, advanced_dock_area): + """Test workspace_is_locked property getter.""" + # Initially unlocked + assert advanced_dock_area.workspace_is_locked is False + + # Set locked state directly + advanced_dock_area._locked = True + assert advanced_dock_area.workspace_is_locked is True + + def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): + """Test workspace_is_locked property setter.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Initially unlocked + assert advanced_dock_area.workspace_is_locked is False + + # Lock workspace + advanced_dock_area.workspace_is_locked = True + assert advanced_dock_area._locked is True + assert advanced_dock_area.workspace_is_locked is True + + # Unlock workspace + advanced_dock_area.workspace_is_locked = False + assert advanced_dock_area._locked is False + assert advanced_dock_area.workspace_is_locked is False + + +class TestDeveloperMode: + """Test developer mode functionality.""" + + def test_developer_mode_toggle(self, advanced_dock_area): + """Test developer mode toggle functionality.""" + # Check initial state + initial_editable = advanced_dock_area._editable + + # Toggle developer mode + advanced_dock_area._on_developer_mode_toggled(True) + assert advanced_dock_area._editable is True + assert advanced_dock_area.workspace_is_locked is False + + advanced_dock_area._on_developer_mode_toggled(False) + assert advanced_dock_area._editable is False + assert advanced_dock_area.workspace_is_locked is True + + def test_set_editable(self, advanced_dock_area): + """Test _set_editable functionality.""" + # Test setting editable to True + advanced_dock_area._set_editable(True) + assert advanced_dock_area.workspace_is_locked is False + assert advanced_dock_area._editable is True + + # Test setting editable to False + advanced_dock_area._set_editable(False) + assert advanced_dock_area.workspace_is_locked is True + assert advanced_dock_area._editable is False + + +class TestToolbarFunctionality: + """Test toolbar setup and functionality.""" + + def test_toolbar_setup(self, advanced_dock_area): + """Test toolbar is properly set up.""" + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "_ACTION_MAPPINGS") + + # Check that action mappings are properly set + assert "menu_plots" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_devices" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_utils" in advanced_dock_area._ACTION_MAPPINGS + + def test_toolbar_plot_actions(self, advanced_dock_area): + """Test plot toolbar actions trigger widget creation.""" + plot_actions = [ + "waveform", + "scatter_waveform", + "multi_waveform", + "image", + "motor_map", + "heatmap", + ] + + for action_name in plot_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_plots = advanced_dock_area.toolbar.components.get_action("menu_plots") + action = menu_plots.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_plots"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_device_actions(self, advanced_dock_area): + """Test device toolbar actions trigger widget creation.""" + device_actions = ["scan_control", "positioner_box"] + + for action_name in device_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_devices = advanced_dock_area.toolbar.components.get_action("menu_devices") + action = menu_devices.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_devices"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_utils_actions(self, advanced_dock_area): + """Test utils toolbar actions trigger widget creation.""" + utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"] + + for action_name in utils_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_utils = advanced_dock_area.toolbar.components.get_action("menu_utils") + action = menu_utils.actions[action_name].action + + # Skip log_panel as it's disabled + if action_name == "log_panel": + assert not action.isEnabled() + continue + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2] + + action.trigger() + if action_name == "terminal": + mock_new.assert_called_once_with( + widget="WebConsole", closable=True, startup_cmd=None + ) + else: + mock_new.assert_called_once_with(widget=widget_type) + + def test_attach_all_action(self, advanced_dock_area, qtbot): + """Test attach_all toolbar action.""" + # Create floating docks + advanced_dock_area.new("DarkModeButton", start_floating=True) + advanced_dock_area.new("DarkModeButton", start_floating=True) + + qtbot.wait(200) + + initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + + # Trigger attach all action + action = advanced_dock_area.toolbar.components.get_action("attach_all").action + action.trigger() + + # Wait a bit for the operation + qtbot.wait(200) + + # Should have fewer or same floating widgets + final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + assert final_floating <= initial_floating + + def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot): + helper = profile_helper(advanced_dock_area) + settings = helper.open_user("floating_profile") + settings.clear() + + settings.setValue("profile/created_at", "2025-11-23T00:00:00Z") + settings.beginWriteArray(SETTINGS_KEYS["manifest"], 2) + + # Floating entry + settings.setArrayIndex(0) + settings.setValue("object_name", "FloatingWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", True) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.1) + settings.setValue("floating_rel_y", 0.1) + settings.setValue("floating_rel_w", 0.2) + settings.setValue("floating_rel_h", 0.2) + settings.setValue("floating_abs_x", 50) + settings.setValue("floating_abs_y", 50) + settings.setValue("floating_abs_w", 200) + settings.setValue("floating_abs_h", 150) + + # Anchored entry + settings.setArrayIndex(1) + settings.setValue("object_name", "EmbeddedWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", False) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) + settings.endArray() + settings.sync() + + advanced_dock_area.delete_all() + advanced_dock_area.load_profile("floating_profile") + + qtbot.waitUntil(lambda: "FloatingWaveform" in advanced_dock_area.dock_map(), timeout=3000) + floating_dock = advanced_dock_area.dock_map()["FloatingWaveform"] + assert floating_dock.isFloating() + + def test_screenshot_action(self, advanced_dock_area, tmpdir): + """Test screenshot toolbar action.""" + # Create a test screenshot file path in tmpdir + screenshot_path = tmpdir.join("test_screenshot.png") + + # Mock the QFileDialog.getSaveFileName to return a test filename + with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)") + + # Mock the screenshot.save method + with mock.patch.object(advanced_dock_area, "grab") as mock_grab: + mock_screenshot = mock.MagicMock() + mock_grab.return_value = mock_screenshot + + # Trigger the screenshot action + action = advanced_dock_area.toolbar.components.get_action("screenshot").action + action.trigger() + + # Verify the dialog was called + mock_dialog.assert_called_once() + + # Verify grab was called + mock_grab.assert_called_once() + + # Verify save was called with the filename + mock_screenshot.save.assert_called_once_with(str(screenshot_path)) + + +class TestDockSettingsDialog: + """Test dock settings dialog functionality.""" + + def test_dock_settings_dialog_init(self, advanced_dock_area): + """Test DockSettingsDialog initialization.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + mock_widget = DarkModeButton(parent=advanced_dock_area) + dialog = DockSettingsDialog(advanced_dock_area, mock_widget) + + assert dialog.windowTitle() == "Dock Settings" + assert dialog.isModal() + assert hasattr(dialog, "prop_editor") + + def test_open_dock_settings_dialog(self, advanced_dock_area, qtbot): + """Test opening dock settings dialog.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + # Create a real dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Mock dialog exec to avoid blocking + with patch.object(DockSettingsDialog, "exec") as mock_exec: + mock_exec.return_value = QDialog.Accepted + + # Call the method + advanced_dock_area._open_dock_settings_dialog(dock, widget) + + # Verify dialog was created and exec called + mock_exec.assert_called_once() + + +class TestSaveProfileDialog: + """Test save profile dialog functionality.""" + + def test_save_profile_dialog_init(self, qtbot): + """Test SaveProfileDialog initialization.""" + dialog = SaveProfileDialog(None, "test_profile") + qtbot.addWidget(dialog) + + assert dialog.windowTitle() == "Save Workspace Profile" + assert dialog.isModal() + assert dialog.name_edit.text() == "test_profile" + + def test_save_profile_dialog_get_values(self, qtbot): + """Test getting values from SaveProfileDialog.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + dialog.name_edit.setText("my_profile") + dialog.quick_select_checkbox.setChecked(True) + + assert dialog.get_profile_name() == "my_profile" + assert dialog.is_quick_select() is True + + def test_save_button_enabled_state(self, qtbot): + """Test save button is enabled/disabled based on name input.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + # Initially should be disabled (empty name) + assert not dialog.save_btn.isEnabled() + + # Should be enabled when name is entered + dialog.name_edit.setText("test") + assert dialog.save_btn.isEnabled() + + # Should be disabled when name is cleared + dialog.name_edit.setText("") + assert not dialog.save_btn.isEnabled() + + def test_accept_blocks_empty_name(self, qtbot): + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + dialog.name_edit.clear() + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.overwrite_existing is False + + def test_accept_readonly_suggests_unique_name(self, qtbot, monkeypatch): + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + + dialog = SaveProfileDialog( + None, + name_exists=lambda name: name == "readonly_custom", + profile_origin=lambda name: "module" if name == "readonly" else "unknown", + origin_label=lambda name: "ModuleDefaults", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("readonly") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("readonly_custom") + assert dialog.overwrite_checkbox.isChecked() is False + assert info_calls, "Expected informational prompt for read-only profile" + + def test_accept_existing_profile_confirm_yes(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: name == "profile_a", + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Accepted + assert dialog.overwrite_existing is True + + def test_accept_existing_profile_confirm_no(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: False, + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("profile_a_custom") + assert dialog.overwrite_existing is False + assert dialog.overwrite_checkbox.isChecked() is False + + def test_overwrite_toggle_sets_and_restores_name(self, qtbot): + dialog = SaveProfileDialog( + None, current_name="custom_name", current_profile_name="existing_profile" + ) + qtbot.addWidget(dialog) + + dialog.overwrite_checkbox.setChecked(True) + assert dialog.name_edit.text() == "existing_profile" + dialog.name_edit.setText("existing_profile") + dialog.overwrite_checkbox.setChecked(False) + assert dialog.name_edit.text() == "custom_name" + + +class TestPreviewPanel: + """Test preview panel scaling behavior.""" + + def test_preview_panel_without_pixmap(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + assert "No preview available" in panel.image_label.text() + + def test_preview_panel_with_pixmap(self, qtbot): + pixmap = QPixmap(40, 20) + pixmap.fill(Qt.red) + panel = PreviewPanel("Current", pixmap) + qtbot.addWidget(panel) + assert panel.image_label.pixmap() is not None + + def test_preview_panel_set_pixmap_resets_placeholder(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + pixmap = QPixmap(30, 30) + pixmap.fill(Qt.blue) + panel.setPixmap(pixmap) + assert panel.image_label.pixmap() is not None + panel.setPixmap(None) + assert panel.image_label.pixmap() is None or panel.image_label.pixmap().isNull() + assert "No preview available" in panel.image_label.text() + + +class TestRestoreProfileDialog: + """Test restore dialog confirmation flow.""" + + def test_confirm_accepts(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Accepted) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is True + + def test_confirm_rejects(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Rejected) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is False + + +class TestProfileInfoAndScreenshots: + """Tests for profile utilities metadata and screenshot helpers.""" + + PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAAFUlEQVQYlWP8//8/A27AhEduBEsDAKXjAxHmByO3AAAAAElFTkSuQmCC" + ) + + def _write_manifest(self, settings, count=2): + settings.beginWriteArray(profile_utils.SETTINGS_KEYS["manifest"], count) + for i in range(count): + settings.setArrayIndex(i) + settings.setValue("object_name", f"widget_{i}") + settings.setValue("widget_class", "Dummy") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + def test_get_profile_info_user_origin(self, temp_profile_dir): + name = "info_user" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") + settings.setValue("profile/author", "Custom") + set_quick_select(name, True) + self._write_manifest(settings, count=3) + + info = get_profile_info(name) + + assert info.name == name + assert info.origin == "settings" + assert info.is_read_only is False + assert info.is_quick_select is True + assert info.widget_count == 3 + assert info.author == "User" + assert info.user_path.endswith(f"{name}.ini") + assert info.size_kb >= 0 + + def test_get_profile_info_default_only(self, temp_profile_dir): + name = "info_default" + settings = open_default_settings(name) + self._write_manifest(settings, count=1) + + user_path = user_profile_path(name) + if os.path.exists(user_path): + os.remove(user_path) + + info = get_profile_info(name) + + assert info.origin == "settings" + assert info.user_path.endswith(f"{name}.ini") + assert info.widget_count == 1 + + def test_get_profile_info_module_readonly(self, module_profile_factory): + name = module_profile_factory("info_readonly") + info = get_profile_info(name) + assert info.origin == "module" + assert info.is_read_only is True + assert info.author == "BEC Widgets" + + def test_get_profile_info_unknown_profile(self): + name = "nonexistent_profile" + if os.path.exists(user_profile_path(name)): + os.remove(user_profile_path(name)) + if os.path.exists(default_profile_path(name)): + os.remove(default_profile_path(name)) + + info = get_profile_info(name) + + assert info.origin == "unknown" + assert info.is_read_only is False + assert info.widget_count == 0 + + def test_load_user_profile_screenshot(self, temp_profile_dir): + name = "user_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_user_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_default_profile_screenshot(self, temp_profile_dir): + name = "default_screenshot" + settings = open_default_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_default_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): + name = "invalid_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is None + + def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): + name = "bytes_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is not None and not pix.isNull() + + +class TestWorkSpaceManager: + """Test workspace manager interactions.""" + + @staticmethod + def _create_profiles(names): + for name in names: + settings = open_user_settings(name) + settings.setValue("meta", "value") + settings.sync() + + def test_render_table_populates_rows(self, qtbot): + profile_names = ["profile_a", "profile_b"] + self._create_profiles(profile_names) + + manager = WorkSpaceManager(target_widget=None) + qtbot.addWidget(manager) + + assert manager.profile_table.rowCount() >= len(profile_names) + + def test_switch_profile_updates_target(self, qtbot, workspace_manager_target): + name = "profile_switch" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.switch_profile(name) + + assert target.load_profile_calls == [name] + assert target._combo.current_text == name + assert manager._current_selected_profile() == name + + def test_toggle_quick_select_updates_flag(self, qtbot, workspace_manager_target): + name = "profile_toggle" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + initial = is_quick_select(name) + manager.toggle_quick_select(name) + + assert is_quick_select(name) is (not initial) + assert target.refresh_calls >= 1 + + def test_save_current_as_profile_with_target(self, qtbot, workspace_manager_target): + name = "profile_save" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.save_current_as_profile() + + assert target.save_called is True + assert manager._current_selected_profile() == name + + def test_delete_profile_removes_files(self, qtbot, workspace_manager_target, monkeypatch): + name = "profile_delete" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) + + manager.delete_profile(name) + + assert not os.path.exists(user_profile_path(name)) + assert target.refresh_calls >= 1 + + def test_delete_readonly_profile_shows_message( + self, qtbot, workspace_manager_target, module_profile_factory, monkeypatch + ): + readonly = module_profile_factory("readonly_delete") + list_profiles() + monkeypatch.setattr( + profile_utils, + "get_profile_info", + lambda *a, **k: profile_utils.ProfileInfo(name=readonly, is_read_only=True), + ) + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + manager = WorkSpaceManager(target_widget=workspace_manager_target()) + qtbot.addWidget(manager) + + manager.delete_profile(readonly) + + assert info_calls, "Expected informational prompt for read-only profile" + + +class TestAdvancedDockAreaRestoreAndDialogs: + """Additional coverage for restore flows and workspace dialogs.""" + + def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_true" + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + pix = QPixmap(8, 8) + pix.fill(Qt.red) + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot", + lambda name, namespace=None: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", + lambda name, namespace=None: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: True, + ) + + with ( + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" + ) as mock_restore, + patch.object(advanced_dock_area, "delete_all") as mock_delete_all, + patch.object(advanced_dock_area, "load_profile") as mock_load_profile, + ): + advanced_dock_area.restore_user_profile_from_default() + + assert mock_restore.call_count == 1 + args, kwargs = mock_restore.call_args + assert args == (profile_name,) + assert kwargs.get("namespace") == advanced_dock_area.profile_namespace + mock_delete_all.assert_called_once() + mock_load_profile.assert_called_once_with(profile_name) + + def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_false" + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: False, + ) + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" + ) as mock_restore: + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_not_called() + + def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): + advanced_dock_area._current_profile_name = None + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm" + ) as mock_confirm: + advanced_dock_area.restore_user_profile_from_default() + mock_confirm.assert_not_called() + + def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): + profile_name = "refresh_profile" + helper = profile_helper(advanced_dock_area) + helper.open_user(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles = MagicMock() + + advanced_dock_area._refresh_workspace_list() + + combo.refresh_profiles.assert_called_once_with(profile_name) + + def test_refresh_workspace_list_fallback(self, advanced_dock_area): + class ComboStub: + def __init__(self): + self.items = [] + self.tooltip = "" + self.block_calls = [] + self.cleared = False + self.current_index = -1 + + def blockSignals(self, value): + self.block_calls.append(value) + + def clear(self): + self.items.clear() + self.cleared = True + + def addItems(self, items): + self.items.extend(items) + + def findText(self, text): + try: + return self.items.index(text) + except ValueError: + return -1 + + def setCurrentIndex(self, idx): + self.current_index = idx + + def setToolTip(self, text): + self.tooltip = text + + active = "active_profile" + quick = "quick_profile" + helper = profile_helper(advanced_dock_area) + helper.open_user(active).sync() + helper.open_user(quick).sync() + helper.set_quick_select(quick, True) + + combo_stub = ComboStub() + + class StubAction: + def __init__(self, widget): + self.widget = widget + + with patch.object( + advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub) + ): + advanced_dock_area._current_profile_name = active + advanced_dock_area._refresh_workspace_list() + + assert combo_stub.block_calls == [True, False] + assert combo_stub.items[0] == active + assert combo_stub.tooltip == "Active profile is not in quick select" + + def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + assert not action.isChecked() + + advanced_dock_area._current_profile_name = "manager_profile" + helper = profile_helper(advanced_dock_area) + helper.open_user("manager_profile").sync() + + advanced_dock_area.show_workspace_manager() + + assert advanced_dock_area.manage_dialog is not None + assert advanced_dock_area.manage_dialog.isVisible() + assert action.isChecked() + assert isinstance(advanced_dock_area.manage_widget, WorkSpaceManager) + + advanced_dock_area.manage_dialog.close() + qtbot.waitUntil(lambda: advanced_dock_area.manage_dialog is None) + assert not action.isChecked() + + def test_manage_dialog_closed(self, advanced_dock_area): + widget_mock = MagicMock() + dialog_mock = MagicMock() + advanced_dock_area.manage_widget = widget_mock + advanced_dock_area.manage_dialog = dialog_mock + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + action.setChecked(True) + + advanced_dock_area._manage_dialog_closed() + + widget_mock.close.assert_called_once() + widget_mock.deleteLater.assert_called_once() + dialog_mock.deleteLater.assert_called_once() + assert advanced_dock_area.manage_dialog is None + assert not action.isChecked() + + +class TestProfileManagement: + """Test profile management functionality.""" + + def test_profile_path(self, temp_profile_dir): + """Test profile path generation.""" + path = user_profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") + assert path == expected + + default_path = default_profile_path("test_profile") + expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") + assert default_path == expected_default + + def test_open_settings(self, temp_profile_dir): + """Test opening settings for a profile.""" + settings = open_user_settings("test_profile") + assert isinstance(settings, QSettings) + + def test_list_profiles_empty(self, temp_profile_dir): + """Test listing profiles when directory is empty.""" + try: + module_defaults = { + os.path.splitext(f)[0] + for f in os.listdir(profile_utils.module_profiles_dir()) + if f.endswith(".ini") + } + except FileNotFoundError: + module_defaults = set() + profiles = list_profiles() + assert module_defaults.issubset(set(profiles)) + + def test_list_profiles_with_files(self, temp_profile_dir): + """Test listing profiles with existing files.""" + # Create some test profile files + profile_names = ["profile1", "profile2", "profile3"] + for name in profile_names: + settings = open_user_settings(name) + settings.setValue("test", "value") + settings.sync() + + profiles = list_profiles() + for name in profile_names: + assert name in profiles + + def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): + """Test read-only profile functionality.""" + profile_name = "user_profile" + + # Initially should not be read-only + assert not is_profile_read_only(profile_name) + + # Create a user profile and ensure it's writable + settings = open_user_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + assert not is_profile_read_only(profile_name) + + # Verify a bundled module profile is detected as read-only + readonly_name = module_profile_factory("module_default") + assert is_profile_read_only(readonly_name) + + def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): + """Test writing and reading dock manifest.""" + settings = open_user_settings("test_manifest") + + # Create real docks + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(1000) + + docks = advanced_dock_area.dock_list() + + # Write manifest + write_manifest(settings, docks) + settings.sync() + + # Read manifest + items = read_manifest(settings) + + assert len(items) >= 3 + for item in items: + assert "object_name" in item + assert "widget_class" in item + assert "closable" in item + assert "floatable" in item + assert "movable" in item + + def test_restore_preserves_quick_select(self, temp_profile_dir): + """Ensure restoring keeps the quick select flag when it was enabled.""" + profile_name = "restorable_profile" + default_settings = open_default_settings(profile_name) + default_settings.setValue("test", "default") + default_settings.sync() + + user_settings = open_user_settings(profile_name) + user_settings.setValue("test", "user") + user_settings.sync() + + set_quick_select(profile_name, True) + assert is_quick_select(profile_name) + + restore_user_from_default(profile_name) + + assert is_quick_select(profile_name) + + +class TestWorkspaceProfileOperations: + """Test workspace profile save/load/delete operations.""" + + def test_save_profile_readonly_conflict( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): + """Test saving profile when read-only profile exists.""" + profile_name = module_profile_factory("readonly_profile") + new_profile = f"{profile_name}_custom" + helper = profile_helper(advanced_dock_area) + target_path = helper.user_path(new_profile) + if os.path.exists(target_path): + os.remove(target_path) + + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog + ): + advanced_dock_area.save_profile(profile_name, show_dialog=True) + + assert os.path.exists(target_path) + + def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): + """Test loading profile with widget manifest.""" + profile_name = "test_load_profile" + helper = profile_helper(advanced_dock_area) + + # Create a profile with manifest + settings = helper.open_user(profile_name) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "test_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + # Load profile + advanced_dock_area.load_profile(profile_name) + + # Wait for widget to be created + qtbot.wait(1000) + + # Check widget was created + widget_map = advanced_dock_area.widget_map() + assert "test_widget" in widget_map + + def test_save_as_skips_autosave_source_profile( + self, advanced_dock_area, temp_profile_dir, qtbot + ): + """Saving a new profile avoids overwriting the source profile during the switch.""" + source_profile = "autosave_source" + new_profile = "autosave_new" + helper = profile_helper(advanced_dock_area) + + settings = helper.open_user(source_profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "source_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(source_profile) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog + ): + advanced_dock_area.save_profile(show_dialog=True) + + qtbot.wait(500) + source_manifest = read_manifest(helper.open_user(source_profile)) + new_manifest = read_manifest(helper.open_user(new_profile)) + + assert len(source_manifest) == 1 + assert len(new_manifest) == 2 + + def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profile_dir, qtbot): + """Regular profile switches should persist the outgoing layout.""" + profile_a = "autosave_keep" + profile_b = "autosave_target" + helper = profile_helper(advanced_dock_area) + + for profile in (profile_a, profile_b): + settings = helper.open_user(profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", f"{profile}_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(profile_a) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + advanced_dock_area.load_profile(profile_b) + qtbot.wait(500) + + manifest_a = read_manifest(helper.open_user(profile_a)) + assert len(manifest_a) == 2 + + def test_delete_profile_readonly( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): + """Test deleting bundled profile removes only the writable copy.""" + profile_name = module_profile_factory("readonly_profile") + helper = profile_helper(advanced_dock_area) + helper.list_profiles() # ensure default and user copies are materialized + helper.open_default(profile_name).sync() + settings = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + user_path = helper.user_path(profile_name) + default_path = helper.default_path(profile_name) + assert os.path.exists(user_path) + assert os.path.exists(default_path) + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with ( + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question", + return_value=QMessageBox.Yes, + ) as mock_question, + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.information", + return_value=None, + ) as mock_info, + ): + advanced_dock_area.delete_profile(show_dialog=True) + + mock_question.assert_not_called() + mock_info.assert_called_once() + # Read-only profile should remain intact (user + default copies) + assert os.path.exists(user_path) + assert os.path.exists(default_path) + + def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): + """Test successful profile deletion.""" + profile_name = "deletable_profile" + helper = profile_helper(advanced_dock_area) + + # Create regular profile + settings = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + user_path = helper.user_path(profile_name) + assert os.path.exists(user_path) + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + + with patch.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh: + advanced_dock_area.delete_profile(show_dialog=True) + + mock_question.assert_called_once() + mock_refresh.assert_called_once() + # Profile should be deleted + assert not os.path.exists(user_path) + + def test_delete_profile_cli_usage(self, advanced_dock_area, temp_profile_dir): + """Test delete_profile with explicit name (CLI usage - no dialog by default).""" + profile_name = "cli_deletable_profile" + helper = profile_helper(advanced_dock_area) + + # Create regular profile + settings = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + user_path = helper.user_path(profile_name) + assert os.path.exists(user_path) + + # Delete without dialog (CLI usage - default behavior) + result = advanced_dock_area.delete_profile(profile_name) + + assert result is True + assert not os.path.exists(user_path) + + def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): + """Test refreshing workspace list.""" + # Create some profiles + helper = profile_helper(advanced_dock_area) + for name in ["profile1", "profile2"]: + settings = helper.open_user(name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.refresh_profiles = MagicMock() + mock_get_action.return_value.widget = mock_combo + + advanced_dock_area._refresh_workspace_list() + + mock_combo.refresh_profiles.assert_called_once() + + +class TestCleanupAndMisc: + """Test cleanup and miscellaneous functionality.""" + + def test_delete_dock(self, advanced_dock_area, qtbot): + """Test _delete_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + initial_count = len(advanced_dock_area.dock_list()) + + # Delete the dock + advanced_dock_area._delete_dock(dock) + + # Wait for deletion to complete + qtbot.wait(200) + + # Verify dock was removed + assert len(advanced_dock_area.dock_list()) == initial_count - 1 + + def test_apply_dock_lock(self, advanced_dock_area, qtbot): + """Test _apply_dock_lock functionality.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Test locking + advanced_dock_area._apply_dock_lock(True) + # No assertion needed - just verify it doesn't crash + + # Test unlocking + advanced_dock_area._apply_dock_lock(False) + # No assertion needed - just verify it doesn't crash + + def test_make_dock(self, advanced_dock_area): + """Test _make_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + initial_count = len(advanced_dock_area.dock_list()) + + # Create dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Verify dock was created + assert dock is not None + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + assert dock.widget() == widget + + def test_install_dock_settings_action(self, advanced_dock_area): + """Test _install_dock_settings_action functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + with patch.object(advanced_dock_area, "_open_dock_settings_dialog") as mock_open_dialog: + dock = advanced_dock_area._make_dock( + widget, closable=True, floatable=True, movable=True + ) + + # Verify dock has settings action + assert hasattr(dock, "setting_action") + assert dock.setting_action is not None + assert dock.setting_action.toolTip() == "Dock settings" + + dock.setting_action.trigger() + mock_open_dialog.assert_called_once_with(dock, widget) + + +class TestModeSwitching: + """Test mode switching functionality.""" + + def test_mode_property_setter_valid_modes(self, advanced_dock_area): + """Test setting valid modes.""" + valid_modes = ["plot", "device", "utils", "creator", "user"] + + for mode in valid_modes: + advanced_dock_area.mode = mode + assert advanced_dock_area.mode == mode + + def test_mode_changed_signal_emission(self, advanced_dock_area, qtbot): + """Test that mode_changed signal is emitted when mode changes.""" + # Set up signal spy + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = "plot" + + # Check signal was emitted with correct argument + assert blocker.args == ["plot"] + + +class TestToolbarModeBundles: + """Test toolbar bundle creation and visibility for different modes.""" + + def test_flat_bundles_created(self, advanced_dock_area): + """Test that flat bundles are created during toolbar setup.""" + # Check that flat bundles exist + assert "flat_plots" in advanced_dock_area.toolbar.bundles + assert "flat_devices" in advanced_dock_area.toolbar.bundles + assert "flat_utils" in advanced_dock_area.toolbar.bundles + + def test_plot_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in plot mode.""" + advanced_dock_area.mode = "plot" + + # Should show only flat_plots bundle (and essential bundles in real implementation) + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_plots" in shown_bundles + + # Should not show other flat bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + # Should not show menu bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + + def test_device_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in device mode.""" + advanced_dock_area.mode = "device" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_devices" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_utils" not in shown_bundles + + def test_utils_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in utils mode.""" + advanced_dock_area.mode = "utils" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_utils" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + + def test_developer_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in developer mode.""" + advanced_dock_area.mode = "creator" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show menu bundles + assert "menu_plots" in shown_bundles + assert "menu_devices" in shown_bundles + assert "menu_utils" in shown_bundles + + # Should show essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_user_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in user mode.""" + advanced_dock_area.mode = "user" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show only essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + # Should not show any widget creation bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + +class TestFlatToolbarActions: + """Test flat toolbar actions functionality.""" + + def test_flat_plot_actions_created(self, advanced_dock_area): + """Test that flat plot actions are created.""" + plot_actions = [ + "flat_waveform", + "flat_scatter_waveform", + "flat_multi_waveform", + "flat_image", + "flat_motor_map", + "flat_heatmap", + ] + + for action_name in plot_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_device_actions_created(self, advanced_dock_area): + """Test that flat device actions are created.""" + device_actions = ["flat_scan_control", "flat_positioner_box"] + + for action_name in device_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_utils_actions_created(self, advanced_dock_area): + """Test that flat utils actions are created.""" + utils_actions = [ + "flat_queue", + "flat_status", + "flat_progress_bar", + "flat_terminal", + "flat_bec_shell", + "flat_log_panel", + "flat_sbb_monitor", + ] + + for action_name in utils_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat plot actions trigger widget creation.""" + plot_action_mapping = { + "flat_waveform": "Waveform", + "flat_scatter_waveform": "ScatterWaveform", + "flat_multi_waveform": "MultiWaveform", + "flat_image": "Image", + "flat_motor_map": "MotorMap", + "flat_heatmap": "Heatmap", + } + + for action_name, widget_type in plot_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget_type) + + def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat device actions trigger widget creation.""" + device_action_mapping = { + "flat_scan_control": "ScanControl", + "flat_positioner_box": "PositionerBox", + } + + for action_name, widget_type in device_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget_type) + + def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat utils actions trigger widget creation.""" + utils_action_mapping = { + "flat_queue": "BECQueue", + "flat_status": "BECStatusBox", + "flat_progress_bar": "RingProgressBar", + "flat_terminal": "WebConsole", + "flat_bec_shell": "BECShell", + "flat_sbb_monitor": "SBBMonitor", + } + + for action_name, widget_type in utils_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + + # Skip log_panel as it's disabled + if action_name == "flat_log_panel": + assert not action.isEnabled() + continue + + action.trigger() + mock_new.assert_called_once_with(widget_type) + + def test_flat_log_panel_action_disabled(self, advanced_dock_area): + """Test that flat log panel action is disabled.""" + action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action + assert not action.isEnabled() + + +class TestModeTransitions: + """Test mode transitions and state consistency.""" + + def test_mode_transition_sequence(self, advanced_dock_area, qtbot): + """Test sequence of mode transitions.""" + modes = ["plot", "device", "utils", "creator", "user"] + + for mode in modes: + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] + + def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): + """Test mode consistency after multiple rapid changes.""" + # Rapidly change modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "utils" + advanced_dock_area.mode = "creator" + advanced_dock_area.mode = "user" + + # Final state should be consistent + assert advanced_dock_area.mode == "user" + + # Toolbar should show correct bundles for user mode + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_toolbar_refresh_on_mode_change(self, advanced_dock_area): + """Test that toolbar is properly refreshed when mode changes.""" + initial_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Change to a different mode + advanced_dock_area.mode = "plot" + plot_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Bundles should be different + assert initial_bundles != plot_bundles + assert "flat_plots" in plot_bundles + + def test_mode_switching_preserves_existing_docks(self, advanced_dock_area, qtbot): + """Test that mode switching doesn't affect existing docked widgets.""" + # Create some widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + initial_dock_count = len(advanced_dock_area.dock_list()) + initial_widget_count = len(advanced_dock_area.widget_list()) + + # Switch modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "user" + + # Dock and widget counts should remain the same + assert len(advanced_dock_area.dock_list()) == initial_dock_count + assert len(advanced_dock_area.widget_list()) == initial_widget_count + + +class TestModeProperty: + """Test mode property getter and setter behavior.""" + + def test_mode_property_getter(self, advanced_dock_area): + """Test mode property getter returns correct value.""" + # Set internal mode directly and test getter + advanced_dock_area._mode = "plot" + assert advanced_dock_area.mode == "plot" + + advanced_dock_area._mode = "device" + assert advanced_dock_area.mode == "device" + + def test_mode_property_setter_updates_internal_state(self, advanced_dock_area): + """Test mode property setter updates internal state.""" + advanced_dock_area.mode = "plot" + assert advanced_dock_area._mode == "plot" + + advanced_dock_area.mode = "utils" + assert advanced_dock_area._mode == "utils" + + def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): + """Test mode property setter triggers toolbar update.""" + with patch.object(advanced_dock_area.toolbar, "show_bundles") as mock_show_bundles: + advanced_dock_area.mode = "plot" + mock_show_bundles.assert_called_once() + + def test_multiple_mode_changes(self, advanced_dock_area, qtbot): + """Test multiple rapid mode changes.""" + modes = ["plot", "device", "utils", "creator", "user"] + + for i, mode in enumerate(modes): + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] diff --git a/tests/unit_tests/test_filter_io.py b/tests/unit_tests/test_filter_io.py index 818faa7d5..e50871245 100644 --- a/tests/unit_tests/test_filter_io.py +++ b/tests/unit_tests/test_filter_io.py @@ -45,3 +45,30 @@ def test_set_selection_line_edit(line_edit_mock): FilterIO.set_selection(line_edit_mock, selection=["testC"]) assert FilterIO.check_input(widget=line_edit_mock, text="testA") is False assert FilterIO.check_input(widget=line_edit_mock, text="testC") is True + + +def test_update_with_signal_class_combo_box_ndim_filter(dap_mock, mocked_client): + signals = [ + ("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}}), + ("dev1", "sig2", {"describe": {"signal_info": {"ndim": 2}}}), + ] + mocked_client.device_manager.get_bec_signals = lambda _filters: signals + out = FilterIO.update_with_signal_class( + widget=dap_mock.fit_model_combobox, + signal_class_filter=["AsyncSignal"], + client=mocked_client, + ndim_filter=1, + ) + assert out == [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] + + +def test_update_with_signal_class_line_edit_passthrough(line_edit_mock, mocked_client): + signals = [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] + mocked_client.device_manager.get_bec_signals = lambda _filters: signals + out = FilterIO.update_with_signal_class( + widget=line_edit_mock, + signal_class_filter=["AsyncSignal"], + client=mocked_client, + ndim_filter=1, + ) + assert out == signals diff --git a/tests/unit_tests/test_guided_tour.py b/tests/unit_tests/test_guided_tour.py new file mode 100644 index 000000000..41d3320ad --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,405 @@ +from unittest import mock + +import pytest +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from bec_widgets.utils.guided_tour import GuidedTour +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + + +@pytest.fixture +def main_window(qtbot): + """Create a main window for testing.""" + window = QWidget() + window.resize(800, 600) + qtbot.addWidget(window) + return window + + +@pytest.fixture +def guided_help(main_window): + """Create a GuidedTour instance for testing.""" + return GuidedTour(main_window, enforce_visibility=False) + + +@pytest.fixture +def test_widget(main_window): + """Create a test widget.""" + widget = QWidget(main_window) + widget.resize(100, 50) + widget.show() + return widget + + +class DummyWidget(QWidget): + """A dummy widget for testing purposes.""" + + def isVisible(self) -> bool: + """Override isVisible to always return True for testing.""" + return True + + +class TestGuidedTour: + """Test the GuidedTour class core functionality.""" + + def test_initialization(self, guided_help): + """Test GuidedTour is properly initialized.""" + assert guided_help.main_window is not None + assert guided_help._registered_widgets == {} + assert guided_help._tour_steps == [] + assert guided_help._current_index == 0 + assert guided_help._active is False + + def test_register_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration creates weak references.""" + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget", title="TestWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Test widget" + assert registered["title"] == "TestWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]() is test_widget + + def test_register_widget_auto_name(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration with automatic naming.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help._registered_widgets[widget_id] + assert registered["title"] == "QWidget" + + def test_create_tour_valid_ids(self, guided_help: GuidedTour, test_widget: QWidget): + """Test creating tour with valid widget IDs.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + result = guided_help.create_tour([widget_id]) + + assert result is True + assert len(guided_help._tour_steps) == 1 + assert guided_help._tour_steps[0]["text"] == "Test widget" + + def test_create_tour_invalid_ids(self, guided_help: GuidedTour): + """Test creating tour with invalid widget IDs.""" + result = guided_help.create_tour(["invalid_id"]) + + assert result is False + assert len(guided_help._tour_steps) == 0 + + def test_start_tour_no_steps(self, guided_help: GuidedTour, test_widget: QWidget): + """Test starting tour with no steps will add all registered widgets.""" + # Register a widget + guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert len(guided_help._tour_steps) == 1 + + def test_start_tour_success(self, guided_help: GuidedTour, test_widget: QWidget): + """Test successful tour start.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert guided_help.overlay is not None + + def test_stop_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test stopping a tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.stop_tour() + + assert guided_help._active is False + + def test_next_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to next step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + + assert guided_help._current_index == 0 + + guided_help.next_step() + + assert guided_help._current_index == 1 + + def test_next_step_finish_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test next step on last step finishes tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.next_step() + + assert guided_help._active is False + + def test_prev_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to previous step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + guided_help.next_step() + + assert guided_help._current_index == 1 + + guided_help.prev_step() + + assert guided_help._current_index == 0 + + def test_get_registered_widgets(self, guided_help: GuidedTour, test_widget: QWidget): + """Test getting registered widgets.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help.get_registered_widgets() + + assert widget_id in registered + assert registered[widget_id]["text"] == "Test widget" + + def test_clear_registrations(self, guided_help: GuidedTour, test_widget: QWidget): + """Test clearing all registrations.""" + guided_help.register_widget(widget=test_widget, text="Test widget") + + guided_help.clear_registrations() + + assert len(guided_help._registered_widgets) == 0 + assert len(guided_help._tour_steps) == 0 + + def test_weak_reference_main_window(self, main_window: QWidget): + """Test that main window is stored as weak reference.""" + guided_help = GuidedTour(main_window) + + # Should be able to get main window through weak reference + assert guided_help.main_window is not None + assert guided_help.main_window == main_window + + def test_complete_tour_flow(self, guided_help: GuidedTour, test_widget: QWidget): + """Test complete tour workflow.""" + # Create widgets + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + # Register widgets + id1 = guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + id2 = guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + # Create and start tour + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + + # Move through tour + guided_help.next_step() + assert guided_help._current_index == 1 + + # Finish tour + guided_help.next_step() + assert guided_help._active is False + + def test_finish_button_on_last_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that the Next button changes to Finish on the last step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + + # First step should show "Next" + assert "Next" in overlay.next_btn.text() + + # Navigate to last step + guided_help.next_step() + + # Last step should show "Finish" + assert "Finish" in overlay.next_btn.text() + + def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that step counter is properly displayed.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + assert overlay.step_label.text() == "Step 1 of 2" + + def test_register_expandable_menu_action(self, qtbot): + """Ensure toolbar menu actions can be registered directly.""" + window = QWidget() + layout = QVBoxLayout(window) + toolbar = ModularToolBar(parent=window) + layout.addWidget(toolbar) + qtbot.addWidget(window) + + tools_action = ExpandableMenuAction( + label="Tools ", + actions={ + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add note", filled=True, parent=window + ) + }, + ) + toolbar.components.add_safe("menu_tools", tools_action) + bundle = toolbar.new_bundle("menu_tools") + bundle.add_action("menu_tools") + toolbar.show_bundles(["menu_tools"]) + + guided = GuidedTour(window, enforce_visibility=False) + guided.register_widget(widget=tools_action, text="Toolbar tools menu") + guided.start_tour() + + assert guided._active is True + + @mock.patch("bec_widgets.utils.guided_tour.logger") + def test_error_handling(self, mock_logger, guided_help): + """Test error handling and logging.""" + # Test with invalid step ID + result = guided_help.create_tour(["invalid_id"]) + assert result is False + mock_logger.error.assert_called() + + def test_memory_safety_widget_deletion(self, guided_help: GuidedTour, test_widget: QWidget): + """Test memory safety when widget is deleted.""" + widget = QWidget(test_widget) + + # Register widget + widget_id = guided_help.register_widget(widget=widget, text="Test widget") + + # Verify weak reference works + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is widget + + # Delete widget + widget.close() + widget.setParent(None) + del widget + + # The weak reference should now return None + # This tests that our weak reference implementation is working + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is None + + def test_unregister_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test unregistering a widget.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + assert widget_id not in guided_help._registered_widgets + + def test_unregister_nonexistent_widget(self, guided_help: GuidedTour): + """Test unregistering a widget that does not exist.""" + # Should not raise an error + assert guided_help.unregister_widget("nonexistent_id") is False + + def test_unregister_widget_removes_from_tour( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget also removes it from the tour steps.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + # The tour steps should no longer contain the unregistered widget + assert len(guided_help._tour_steps) == 0 + + def test_unregister_widget_during_tour_raises( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget during an active tour raises an error.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + with pytest.raises(RuntimeError): + guided_help.unregister_widget(widget_id) + + def test_register_lambda_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a lambda function as a widget.""" + widget_id = guided_help.register_widget( + widget=lambda: (test_widget, "test text"), text="Lambda widget", title="LambdaWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Lambda widget" + assert registered["title"] == "LambdaWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "test text" + + def test_register_widget_local_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a local function as a widget.""" + + def local_widget_function(): + return test_widget, "local text" + + widget_id = guided_help.register_widget( + widget=local_widget_function, text="Local function widget", title="LocalWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Local function widget" + assert registered["title"] == "LocalWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "local text" + + def test_text_accepts_html_content(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """Test that registered text can contain HTML content.""" + html_text = ( + "Bold Text with Italics and a link." + ) + widget_id = guided_help.register_widget( + widget=test_widget, text=html_text, title="HTMLWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == html_text + + def test_overlay_painter(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """ + Test that the overlay painter works without errors. + While we cannot directly test the visual output, we can ensure + that calling the paintEvent does not raise exceptions. + """ + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget for overlay", title="OverlayWidget" + ) + widget = guided_help._registered_widgets[widget_id]["widget_ref"]() + with mock.patch.object(widget, "isVisible", return_value=True): + guided_help.start_tour() + guided_help.overlay.paintEvent(None) # Force paint event to render text + qtbot.wait(300) # Wait for rendering diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index ff2d42741..fe8e0ee87 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -5,8 +5,15 @@ from bec_lib import messages from bec_lib.scan_history import ScanHistory from qtpy.QtCore import QPointF +from qtpy.QtGui import QTransform -from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal +from bec_widgets.widgets.plots.heatmap.heatmap import ( + Heatmap, + HeatmapConfig, + HeatmapDeviceSignal, + _InterpolationRequest, + _StepInterpolationWorker, +) # pytest: disable=unused-import from tests.unit_tests.client_mocks import mocked_client @@ -448,12 +455,16 @@ def test_heatmap_widget_reset(heatmap_widget): """ Test that the reset method clears the plot. """ + heatmap_widget._pending_interpolation_request = object() + heatmap_widget._latest_interpolation_version = 5 heatmap_widget.scan_item = create_dummy_scan_item() heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") heatmap_widget.reset() assert heatmap_widget._grid_index is None assert heatmap_widget.main_image.raw_data is None + assert heatmap_widget._pending_interpolation_request is None + assert heatmap_widget._latest_interpolation_version == 5 def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot): @@ -478,3 +489,403 @@ def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_ heatmap_widget.enforce_interpolation = True heatmap_widget.oversampling_factor = 2.0 qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (20, 20)) + + +def test_step_interpolation_worker_emits_finished(qtbot): + worker = _StepInterpolationWorker() + request = _InterpolationRequest( + x_data=[0.0, 1.0, 0.5, 0.2], + y_data=[0.0, 0.0, 1.0, 1.0], + z_data=[1.0, 2.0, 3.0, 4.0], + data_version=4, + scan_id="scan-1", + interpolation="linear", + oversampling_factor=1.0, + ) + with qtbot.waitSignal(worker.finished, timeout=1000) as blocker: + worker.process(request, request.data_version) + img, transform, data_version, scan_id = blocker.args + assert img.shape[0] > 0 + assert isinstance(transform, QTransform) + assert data_version == request.data_version + assert scan_id == request.scan_id + + +def test_step_interpolation_worker_emits_failed(qtbot, monkeypatch): + def _scan_goes_boom(**kwargs): + raise RuntimeError("crash") + + monkeypatch.setattr( + "bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.compute_step_scan_image", _scan_goes_boom + ) + worker = _StepInterpolationWorker() + request = _InterpolationRequest( + x_data=[0.0, 1.0, 0.5, 0.2], + y_data=[0.0, 0.0, 1.0, 1.0], + z_data=[1.0, 2.0, 3.0, 4.0], + data_version=99, + scan_id="scan-err", + interpolation="linear", + oversampling_factor=1.0, + ) + with qtbot.waitSignal(worker.failed, timeout=1000) as blocker: + worker.process(request, request.data_version) + error, data_version, scan_id = blocker.args + assert "crash" in error + assert data_version == request.data_version + assert scan_id == request.scan_id + + +def test_interpolation_generation_invalidation(heatmap_widget): + heatmap_widget.scan_id = "scan-1" + heatmap_widget._latest_interpolation_version = 2 + with ( + mock.patch.object(heatmap_widget, "_apply_image_update") as apply_mock, + mock.patch.object(heatmap_widget, "_maybe_start_pending_interpolation") as maybe_mock, + ): + heatmap_widget._on_interpolation_finished( + np.zeros((2, 2)), QTransform(), data_version=1, scan_id="scan-1" + ) + apply_mock.assert_not_called() + maybe_mock.assert_called_once() + + +def test_pending_request_queueing_and_start(heatmap_widget): + heatmap_widget.scan_id = "scan-queue" + heatmap_widget.status_message = messages.ScanStatusMessage( + scan_id="scan-queue", + status="open", + scan_name="step_scan", + scan_type="step", + metadata={}, + info={"positions": [[0, 0], [1, 1], [2, 2], [3, 3]]}, + ) + # Simulate an active worker processing a job so new requests are queued. + heatmap_widget._interpolation_worker = mock.MagicMock() + heatmap_widget._interpolation_worker.is_processing = True + + with mock.patch.object(heatmap_widget, "_start_step_scan_interpolation") as start_mock: + heatmap_widget._request_step_scan_interpolation( + x_data=[0, 1, 2, 3], + y_data=[0, 1, 2, 3], + z_data=[0, 1, 2, 3], + msg=heatmap_widget.status_message, + ) + assert heatmap_widget._pending_interpolation_request is not None + + # Now simulate worker finished and thread cleaned up + heatmap_widget._interpolation_worker.is_processing = False + pending = heatmap_widget._pending_interpolation_request + heatmap_widget._pending_interpolation_request = pending + heatmap_widget._maybe_start_pending_interpolation() + + start_mock.assert_called_once() + + +def test_finish_interpolation_thread_cleans_references(heatmap_widget): + worker_mock = mock.Mock() + thread_mock = mock.Mock() + thread_mock.isRunning.return_value = True + heatmap_widget._interpolation_worker = worker_mock + heatmap_widget._interpolation_thread = thread_mock + + heatmap_widget._finish_interpolation_thread() + + worker_mock.deleteLater.assert_called_once() + thread_mock.quit.assert_called_once() + thread_mock.wait.assert_called_once() + thread_mock.deleteLater.assert_called_once() + assert heatmap_widget._interpolation_worker is None + assert heatmap_widget._interpolation_thread is None + + +def test_device_safe_properties_get(heatmap_widget): + """Test that device SafeProperty getters work correctly.""" + # Initially devices should be empty + assert heatmap_widget.x_device_name == "" + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.y_device_entry == "" + assert heatmap_widget.z_device_name == "" + assert heatmap_widget.z_device_entry == "" + + # Set devices via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Check properties return device names and entries separately + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.x_device_entry # Should have some entry + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.y_device_entry # Should have some entry + assert heatmap_widget.z_device_name == "bpm4i" + assert heatmap_widget.z_device_entry # Should have some entry + + +def test_device_safe_properties_set_name(heatmap_widget): + """Test that device SafeProperty setters work for device names.""" + # Set x_device_name - should auto-validate entry + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.x_device.name == "samx" + assert heatmap_widget._image_config.x_device.entry is not None # Entry should be validated + assert heatmap_widget.x_device_name == "samx" + + # Set y_device_name + heatmap_widget.y_device_name = "samy" + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.y_device.name == "samy" + assert heatmap_widget._image_config.y_device.entry is not None + assert heatmap_widget.y_device_name == "samy" + + # Set z_device_name + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget._image_config.z_device is not None + assert heatmap_widget._image_config.z_device.name == "bpm4i" + assert heatmap_widget._image_config.z_device.entry is not None + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_safe_properties_set_entry(heatmap_widget): + """Test that device entry properties can override default entries.""" + # Set device name first - this auto-validates entry + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.x_device_entry + assert initial_entry # Should have auto-validated entry + + # Override with specific entry + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget._image_config.x_device.entry == "samx" + assert heatmap_widget.x_device_entry == "samx" + + # Same for y device + heatmap_widget.y_device_name = "samy" + heatmap_widget.y_device_entry = "samy_setpoint" + assert heatmap_widget._image_config.y_device.entry == "samy_setpoint" + + # Same for z device + heatmap_widget.z_device_name = "bpm4i" + heatmap_widget.z_device_entry = "bpm4i" + assert heatmap_widget._image_config.z_device.entry == "bpm4i" + + +def test_device_entry_cannot_be_set_without_name(heatmap_widget): + """Test that setting entry without device name logs warning and does nothing.""" + # Try to set entry without device name + heatmap_widget.x_device_entry = "some_entry" + # Should not crash, entry should remain empty + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_set_empty(heatmap_widget): + """Test that device SafeProperty setters handle empty strings.""" + # Set device first + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + + # Set to empty string - should clear the device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_auto_plot(heatmap_widget): + """Test that setting all three devices triggers auto-plot.""" + # Set all three devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Check that plot was called (image_config should be updated) + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.z_device is not None + + +def test_device_properties_update_labels(heatmap_widget): + """Test that setting device properties updates axis labels.""" + # Set x device - should update x label + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_label == "samx" + + # Set y device - should update y label + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.y_label == "samy" + + # Set z device - should update title + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget.title == "bpm4i" + + +def test_device_properties_partial_configuration(heatmap_widget): + """Test that widget handles partial device configuration gracefully.""" + # Set only x device + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.z_device_name == "" + + # Set only y device (x already set) + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "" + + # Auto-plot should not trigger yet (z missing) + # But devices should be configured + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + + +def test_device_properties_in_user_access(heatmap_widget): + """Test that device properties are exposed in USER_ACCESS for RPC.""" + from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap + + assert "x_device_name" in Heatmap.USER_ACCESS + assert "x_device_name.setter" in Heatmap.USER_ACCESS + assert "x_device_entry" in Heatmap.USER_ACCESS + assert "x_device_entry.setter" in Heatmap.USER_ACCESS + assert "y_device_name" in Heatmap.USER_ACCESS + assert "y_device_name.setter" in Heatmap.USER_ACCESS + assert "y_device_entry" in Heatmap.USER_ACCESS + assert "y_device_entry.setter" in Heatmap.USER_ACCESS + assert "z_device_name" in Heatmap.USER_ACCESS + assert "z_device_name.setter" in Heatmap.USER_ACCESS + assert "z_device_entry" in Heatmap.USER_ACCESS + assert "z_device_entry.setter" in Heatmap.USER_ACCESS + + +def test_device_properties_validation(heatmap_widget): + """Test that device entries are validated through entry_validator.""" + # Set device name - entry should be auto-validated + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.x_device_entry + + # The entry should be validated (will be "samx" in the mock) + assert initial_entry == "samx" + + # Set a different entry - should also be validated + heatmap_widget.x_device_entry = "samx" # Use same name as validated entry + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_plot_method(heatmap_widget): + """Test that device properties reflect values set via plot() method.""" + # Use plot method + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Properties should reflect the plotted devices + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + # Entries should be validated + assert heatmap_widget.x_device_entry == "samx" + assert heatmap_widget.y_device_entry == "samy" + assert heatmap_widget.z_device_entry == "bpm4i" + + +def test_device_properties_overwrite_via_properties(heatmap_widget): + """Test that device properties can overwrite values set via plot().""" + # First set via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Overwrite x device via properties + heatmap_widget.x_device_name = "samz" + assert heatmap_widget.x_device_name == "samz" + assert heatmap_widget._image_config.x_device.name == "samz" + + # Overwrite y device entry + heatmap_widget.y_device_entry = "samy" + assert heatmap_widget.y_device_entry == "samy" + + +def test_device_properties_clearing_devices(heatmap_widget): + """Test clearing devices by setting to empty string.""" + # Set all devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Clear x device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + # Y and Z should still be set + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_properties_property_changed_signal(heatmap_widget): + """Test that property_changed signal is emitted when devices are set.""" + from unittest.mock import Mock + + # Connect mock to property_changed signal + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + # Set device name + heatmap_widget.x_device_name = "samx" + + # Signal should have been emitted + assert mock_handler.called + # Check it was called with correct arguments + mock_handler.assert_any_call("x_device_name", "samx") + + +def test_auto_emit_syncs_heatmap_toolbar_actions(heatmap_widget): + from unittest.mock import Mock + + fft_action = heatmap_widget.toolbar.components.get_action("image_processing_fft").action + log_action = heatmap_widget.toolbar.components.get_action("image_processing_log").action + + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + heatmap_widget.fft = True + heatmap_widget.log = True + + assert fft_action.isChecked() + assert log_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + + +def test_device_entry_validation_with_invalid_device(heatmap_widget): + """Test that invalid device names are handled gracefully.""" + # Try to set invalid device name + heatmap_widget.x_device_name = "nonexistent_device" + + # Should not crash, but device might not be set if validation fails + # The implementation silently fails, so we just check it doesn't crash + + +def test_device_properties_sequential_entry_changes(heatmap_widget): + """Test changing device entry multiple times.""" + # Set device + heatmap_widget.x_device_name = "samx" + + # Change entry multiple times + heatmap_widget.x_device_entry = "samx_velocity" + assert heatmap_widget.x_device_entry == "samx_velocity" + + heatmap_widget.x_device_entry = "samx_setpoint" + assert heatmap_widget.x_device_entry == "samx_setpoint" + + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_none_values(heatmap_widget): + """Test that None values are handled as empty strings.""" + # Device name None should be treated as empty + heatmap_widget.x_device_name = None + assert heatmap_widget.x_device_name == "" + + # Set a device first + heatmap_widget.y_device_name = "samy" + + # Entry None should not change anything + heatmap_widget.y_device_entry = None + assert heatmap_widget.y_device_entry # Should still have validated entry diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py new file mode 100644 index 000000000..5ab96274f --- /dev/null +++ b/tests/unit_tests/test_help_inspector.py @@ -0,0 +1,132 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +from unittest import mock + +import pytest +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.utils.widget_io import WidgetHierarchy +from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton + +from .client_mocks import mocked_client + + +@pytest.fixture +def help_inspector(qtbot, mocked_client): + widget = HelpInspector(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def abort_button(qtbot): + widget = AbortButton() + widget.setToolTip("This is an abort button.") + + def get_help_md(): + return "This is **markdown** help text for the abort button." + + widget.get_help_md = get_help_md # type: ignore + + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + yield widget + + +def test_help_inspector_button(help_inspector): + """Test the HelpInspector widget.""" + assert not help_inspector._active + help_inspector._button.click() + assert help_inspector._active + assert help_inspector._button.isChecked() + cursor = QtWidgets.QApplication.overrideCursor() + assert cursor is not None + assert cursor.shape() == QtCore.Qt.CursorShape.WhatsThisCursor + help_inspector._button.click() + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_register_callback(help_inspector): + """Test registering a callback in the HelpInspector widget.""" + + assert len(help_inspector._callbacks) == 3 # default callbacks + + def my_callback(widget): + pass + + cb_id = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 4 + assert help_inspector._callbacks[cb_id] == my_callback + + cb_id2 = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 5 + assert help_inspector._callbacks[cb_id2] == my_callback + + help_inspector.unregister_callback(cb_id) + assert len(help_inspector._callbacks) == 4 + + help_inspector.unregister_callback(cb_id2) + assert len(help_inspector._callbacks) == 3 + + +def test_help_inspector_escape_key(qtbot, help_inspector): + """Test that pressing the Escape key deactivates the HelpInspector.""" + help_inspector._button.click() + assert help_inspector._active + qtbot.keyClick(help_inspector, QtCore.Qt.Key.Key_Escape) + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_event_filter(help_inspector, abort_button): + """Test the event filter of the HelpInspector.""" + # Test nothing happens when not active + obj = mock.MagicMock(spec=QtWidgets.QWidget) + event = mock.MagicMock(spec=QtCore.QEvent) + assert help_inspector._active is False + with mock.patch.object( + QtWidgets.QWidget, "eventFilter", return_value=False + ) as super_event_filter: + help_inspector.eventFilter(obj, event) # should do nothing and return False + super_event_filter.assert_called_once_with(obj, event) + super_event_filter.reset_mock() + + help_inspector._active = True + with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle: + # Key press Escape + event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress) + event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape) + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + + # Click on itself + event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress) + event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton) + event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1)) + with mock.patch.object( + help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button] + ): + # Return for self call + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + # Run Callback for abort_button + callback_data = [] + + def _my_callback(widget): + callback_data.append(widget) + + help_inspector.register_callback(_my_callback) + + help_inspector.eventFilter(obj, event) + mock_toggle.assert_not_called() + assert len(callback_data) == 1 + assert callback_data[0] == abort_button + callback_data.clear() diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index ba1b9eecc..cfdf3d5f0 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -1,7 +1,9 @@ import os +from pathlib import Path from unittest import mock import pytest +from qtpy.QtWidgets import QMessageBox from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -34,3 +36,423 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): ): ide_explorer._add_local_script() assert os.path.exists(os.path.join(tmpdir, "test_file.py")) + + +def test_shared_scripts_section_with_files(ide_explorer, tmpdir): + """Test that shared scripts section is created when plugin directory has files""" + # Create dummy shared script files + shared_scripts_dir = tmpdir.mkdir("shared_scripts") + shared_scripts_dir.join("shared_script1.py").write("# Shared script 1") + shared_scripts_dir.join("shared_script2.py").write("# Shared script 2") + + ide_explorer.clear() + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_scripts_dir) + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should have both Local and Shared sections + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_macros_section_with_files(ide_explorer, tmpdir): + """Test that shared macros section is created when plugin directory has files""" + # Create dummy shared macro files + shared_macros_dir = tmpdir.mkdir("shared_macros") + shared_macros_dir.join("shared_macro1.py").write( + """ +def shared_function1(): + return "shared1" + +def shared_function2(): + return "shared2" +""" + ) + shared_macros_dir.join("utilities.py").write( + """ +def utility_function(): + return "utility" +""" + ) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_macros_dir) + + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + macros_section = ide_explorer.main_explorer.get_section("MACROS") + assert macros_section is not None + + # Should have both Local and Shared sections + local_section = macros_section.content_widget.get_section("Local") + shared_section = macros_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer): + """Test that shared sections are not added when plugin directories don't exist""" + ide_explorer.clear() + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = None + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir): + """Test that shared sections are not added when plugin directory doesn't exist on disk""" + ide_explorer.clear() + # Return a path that doesn't exist + nonexistent_path = str(tmpdir.join("nonexistent")) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = nonexistent_path + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section since directory doesn't exist + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +@pytest.mark.parametrize( + "slot, signal, file_name,scope", + [ + ( + "_emit_file_open_scripts_local", + "file_open_requested", + "example_script.py", + "scripts/local", + ), + ( + "_emit_file_preview_scripts_local", + "file_preview_requested", + "example_macro.py", + "scripts/local", + ), + ( + "_emit_file_open_scripts_shared", + "file_open_requested", + "example_script.py", + "scripts/shared", + ), + ( + "_emit_file_preview_scripts_shared", + "file_preview_requested", + "example_macro.py", + "scripts/shared", + ), + ], +) +def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope): + """Test that the correct signals are emitted when files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(file_name) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_name, scope)] + + +@pytest.mark.parametrize( + "slot, signal, func_name, file_path,scope", + [ + ( + "_emit_file_open_macros_local", + "file_open_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_preview_macros_local", + "file_preview_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_open_macros_shared", + "file_open_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ( + "_emit_file_preview_macros_shared", + "file_preview_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ], +) +def test_ide_explorer_file_signals_macros( + ide_explorer, qtbot, slot, signal, func_name, file_path, scope +): + """Test that the correct signals are emitted when macro files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(func_name, file_path) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_path, scope)] + + +def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir): + """Test adding a local macro through the UI""" + # Create macros section first + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("test_macro_function", True), + ): + ide_explorer._add_local_macro() + + # Check that the macro file was created + expected_file = os.path.join(tmpdir, "test_macro_function.py") + assert os.path.exists(expected_file) + + # Check that the file contains the expected function + with open(expected_file, "r") as f: + content = f.read() + assert "def test_macro_function():" in content + assert "test_macro_function macro" in content + + +def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir): + """Test adding a local macro with invalid function name""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Test with invalid function name (starts with number) + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("123invalid", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning" + ) as mock_warning, + ): + ide_explorer._add_local_macro() + + # Should show warning message + mock_warning.assert_called_once() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir): + """Test adding a local macro when file already exists""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create an existing file + existing_file = Path(tmpdir) / "existing_macro.py" + existing_file.write_text("# Existing macro") + + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("existing_macro", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question", + return_value=QMessageBox.StandardButton.Yes, + ) as mock_question, + ): + ide_explorer._add_local_macro() + + # Should ask for overwrite confirmation + mock_question.assert_called_once() + + # File should be overwritten with new content + with open(existing_file, "r") as f: + content = f.read() + assert "def existing_macro():" in content + + +def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir): + """Test cancelling the add local macro dialog""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # User cancels the dialog + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("", False), # User cancelled + ): + ide_explorer._add_local_macro() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_reload_macros_success(ide_explorer, qtbot): + """Test successful macro reloading""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock the client and macros + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information" + ) as mock_info: + ide_explorer._reload_macros() + + # Should call load_all_user_macros + mock_macros.load_all_user_macros.assert_called_once() + + # Should show success message + mock_info.assert_called_once() + assert "successfully" in mock_info.call_args[0][2] + + +def test_ide_explorer_reload_macros_error(ide_explorer, qtbot): + """Test macro reloading when an error occurs""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock client with macros that raises an exception + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_macros.load_all_user_macros.side_effect = Exception("Test error") + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical" + ) as mock_critical: + ide_explorer._reload_macros() + + # Should show error message + mock_critical.assert_called_once() + assert "Failed to reload macros" in mock_critical.call_args[0][2] + + +def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir): + """Test refreshing a local macro file""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create a test macro file + macro_file = Path(tmpdir) / "test_macro.py" + macro_file.write_text("def test_function(): pass") + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(str(macro_file)) + + # Should call refresh_file_item with the file path + mock_refresh.assert_called_once_with(str(macro_file)) + + +def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir): + """Test refreshing a macro file that doesn't match any directory""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Try to refresh a file that's not in any macro directory + unrelated_file = "/some/other/path/unrelated.py" + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(unrelated_file) + + # Should not call refresh_file_item + mock_refresh.assert_not_called() + + +def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot): + """Test refreshing a macro file when no macro sections exist""" + ide_explorer.clear() + # Don't add macros section + + # Should handle gracefully without error + ide_explorer.refresh_macro_file("/some/path/test.py") + # Test passes if no exception is raised diff --git a/tests/unit_tests/test_image_layer.py b/tests/unit_tests/test_image_layer.py index 078ffa0af..c5463b4b3 100644 --- a/tests/unit_tests/test_image_layer.py +++ b/tests/unit_tests/test_image_layer.py @@ -4,7 +4,6 @@ import pytest from bec_widgets.widgets.plots.image.image_base import ImageLayerManager -from bec_widgets.widgets.plots.image.image_item import ImageItem @pytest.fixture() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 78e34705b..6144468d8 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -1,6 +1,7 @@ import numpy as np import pyqtgraph as pg import pytest +from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QPointF from bec_widgets.widgets.plots.image.image import Image @@ -12,6 +13,23 @@ ################################################## +def _set_signal_config( + client, + device_name: str, + signal_name: str, + signal_class: str, + ndim: int, + obj_name: str | None = None, +): + device = client.device_manager.devices[device_name] + device._info["signals"][signal_name] = { + "obj_name": obj_name or signal_name, + "signal_class": signal_class, + "component_name": signal_name, + "describe": {"signal_info": {"ndim": ndim}}, + } + + def test_initialization_defaults(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) assert bec_image_view.color_map == "plasma" @@ -114,32 +132,35 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type): ############################################## -# Preview‑signal update mechanism +# Device/signal update mechanism -def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_1d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 1‑D PreviewSignal connects using the 1‑D path and updates correctly. + Ensure that calling .image() with a 1‑D PreviewSignal connects using the 1‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "waveform1d_img", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 1}}, - } + _set_signal_config( + mocked_client, + "waveform1d", + "img", + signal_class="PreviewSignal", + ndim=1, + obj_name="waveform1d_img", + ) - # Set the image monitor to the preview signal - view.image(monitor=("waveform1d", "img", signal_config)) + view.image(device_name="waveform1d", device_entry="img") # Subscriptions should indicate 1‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_1d" assert sub.monitor_type == "1d" - assert sub.monitor == ("waveform1d", "img", signal_config) + assert view.device_name == "waveform1d" + assert view.device_entry == "img" # Simulate a waveform update from the dispatcher waveform = np.arange(25, dtype=float) @@ -148,29 +169,32 @@ def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.raw_data[0], waveform) -def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_2d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 2‑D PreviewSignal connects using the 2‑D path and updates correctly. + Ensure that calling .image() with a 2‑D PreviewSignal connects using the 2‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "eiger_img2d", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 2}}, - } + _set_signal_config( + mocked_client, + "eiger", + "img2d", + signal_class="PreviewSignal", + ndim=2, + obj_name="eiger_img2d", + ) - # Set the image monitor to the preview signal - view.image(monitor=("eiger", "img2d", signal_config)) + view.image(device_name="eiger", device_entry="img2d") # Subscriptions should indicate 2‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_2d" assert sub.monitor_type == "2d" - assert sub.monitor == ("eiger", "img2d", signal_config) + assert view.device_name == "eiger" + assert view.device_entry == "img2d" # Simulate a 2‑D image update test_data = np.arange(16, dtype=float).reshape(4, 4) @@ -178,38 +202,197 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.image, test_data) +def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch): + """ + Preview/async combobox should omit 0‑D signals. + """ + view = create_widget(qtbot, Image, client=mocked_client) + + def fake_get(signal_class_filter): + signal_classes = ( + signal_class_filter + if isinstance(signal_class_filter, (list, tuple, set)) + else [signal_class_filter] + ) + if "PreviewSignal" in signal_classes: + return [ + ( + "eiger", + "sig0d", + { + "obj_name": "sig0d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 0}}, + }, + ), + ( + "eiger", + "sig2d", + { + "obj_name": "sig2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ), + ] + return [] + + monkeypatch.setattr(view.client.device_manager, "get_bec_signals", fake_get) + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.set_device("eiger") + device_selection.signal_combo_box.update_signals_from_signal_classes() + + texts = [ + device_selection.signal_combo_box.itemText(i) + for i in range(device_selection.signal_combo_box.count()) + ] + assert "sig0d" not in texts + assert "sig2d" in texts + + +def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch): + """ + Verify async signals use obj_name for endpoints/payloads and reconnect with scan_id. + """ + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + assert view.subscriptions["main"].async_signal_name == "async_obj" + assert view.async_update is True + + # Prepare scan ids and capture dispatcher calls + view.old_scan_id = "old_scan" + view.scan_id = "new_scan" + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, from_start=False, cb_info=None: connected.append( + (slot, endpoint, from_start, cb_info) + ), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint: disconnected.append((slot, endpoint)), + ) + + view._setup_async_image(view.scan_id) + + expected_new = MessageEndpoints.device_async_signal("new_scan", "eiger", "async_obj") + expected_old = MessageEndpoints.device_async_signal("old_scan", "eiger", "async_obj") + assert any(ep == expected_new for _, ep, _, _ in connected) + assert any(ep == expected_old for _, ep in disconnected) + + # Payload extraction should use obj_name + payload = np.array([1, 2, 3]) + msg = {"signals": {"async_obj": {"value": payload}}} + assert np.array_equal(view._get_payload_data(msg), payload) + + +def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + view.scan_id = "scan_x" + view.old_scan_id = "scan_y" + view.subscriptions["main"].async_signal_name = "async_obj" + + # Avoid touching real dispatcher + monkeypatch.setattr(view.bec_dispatcher, "disconnect_slot", lambda *args, **kwargs: None) + + view.disconnect_monitor(device_name="eiger", device_entry="img") + + assert view.subscriptions["main"].async_signal_name is None + assert view.async_update is False + + ############################################## -# Device monitor endpoint update mechanism +# Connection guardrails -def test_image_setup_image_2d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="2d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" - assert bec_image_view.subscriptions["main"].monitor_type == "2d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="Signal", ndim=2) + view.image(device_name="eiger", device_entry="img") -def test_image_setup_image_1d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="1d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_1d" - assert bec_image_view.subscriptions["main"].monitor_type == "1d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None + assert view.subscriptions["main"].source is None + assert view.subscriptions["main"].monitor_type is None + assert view.async_update is False -def test_image_setup_image_auto(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="auto") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "auto" - assert bec_image_view.subscriptions["main"].monitor_type == "auto" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_disconnects_with_missing_entry(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + + view.image(device_name="eiger", device_entry="img") + assert view.device_name == "eiger" + assert view.device_entry == "img" + + view.image(device_name="eiger", device_entry=None) + assert view.device_name == "" + assert view.device_entry == "" + + +def test_handle_scan_change_clears_buffers_and_resets_crosshair(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0, 2.0])] + view.main_image.max_len = 2 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + reset_called = [] + if view.crosshair is not None: + monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True)) + + view._handle_scan_change("scan_2") + + assert view.old_scan_id == "scan_1" + assert view.scan_id == "scan_2" + assert clear_called == [True] + assert view.main_image.buffer == [] + assert view.main_image.max_len == 0 + if view.crosshair is not None: + assert reset_called == [True] + + +def test_handle_scan_change_reconnects_async(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.async_update = True + + called = [] + monkeypatch.setattr(view, "_setup_async_image", lambda scan_id: called.append(scan_id)) + + view._handle_scan_change("scan_2") + + assert called == ["scan_2"] + + +def test_handle_scan_change_same_scan_noop(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0])] + view.main_image.max_len = 1 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + + view._handle_scan_change("scan_1") + + assert view.scan_id == "scan_1" + assert clear_called == [] + assert view.main_image.buffer == [np.array([1.0])] + assert view.main_image.max_len == 1 def test_image_data_update_2d(qtbot, mocked_client): @@ -245,8 +428,32 @@ def test_toolbar_actions_presence(qtbot, mocked_client): assert bec_image_view.toolbar.components.exists("image_autorange") assert bec_image_view.toolbar.components.exists("lock_aspect_ratio") assert bec_image_view.toolbar.components.exists("image_processing_fft") - assert bec_image_view.toolbar.components.exists("image_device_combo") - assert bec_image_view.toolbar.components.exists("image_dim_combo") + assert bec_image_view.toolbar.components.exists("device_selection") + + +def test_auto_emit_syncs_image_toolbar_actions(qtbot, mocked_client): + from unittest.mock import Mock + + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + fft_action = bec_image_view.toolbar.components.get_action("image_processing_fft").action + log_action = bec_image_view.toolbar.components.get_action("image_processing_log").action + transpose_action = bec_image_view.toolbar.components.get_action( + "image_processing_transpose" + ).action + + mock_handler = Mock() + bec_image_view.property_changed.connect(mock_handler) + + bec_image_view.fft = True + bec_image_view.log = True + bec_image_view.transpose = True + + assert fft_action.isChecked() + assert log_action.isChecked() + assert transpose_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + mock_handler.assert_any_call("transpose", True) def test_image_processing_fft_toggle(qtbot, mocked_client): @@ -302,13 +509,40 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type): ################################### -def test_setup_image_from_toolbar(qtbot, mocked_client): +def test_setup_image_from_toolbar(qtbot, mocked_client, monkeypatch): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.device_combo_box.setCurrentText("eiger") - bec_image_view.dim_combo_box.setCurrentText("2d") + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + mocked_client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img", + { + "obj_name": "img", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) + ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) + + device_selection = bec_image_view.toolbar.components.get_action("device_selection").widget + device_selection.device_combo_box.update_devices_from_filters() + device_selection.device_combo_box.setCurrentText("eiger") + device_selection.signal_combo_box.setCurrentText("img") - assert bec_image_view.monitor == "eiger" + bec_image_view.on_device_selection_changed(None) + qtbot.wait(200) + + assert bec_image_view.device_name == "eiger" + assert bec_image_view.device_entry == "img" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" assert bec_image_view.subscriptions["main"].monitor_type == "2d" assert bec_image_view.main_image.raw_data is None @@ -573,90 +807,59 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): ############################################## -# MonitorSelectionToolbarBundle specific tests +# Device selection toolbar sync ############################################## -def test_monitor_selection_reverse_device_items(qtbot, mocked_client): - """ - Verify that _reverse_device_items correctly reverses the order of items in the - device combobox while preserving the current selection. - """ +def test_device_selection_syncs_from_properties(qtbot, mocked_client, monkeypatch): view = create_widget(qtbot, Image, client=mocked_client) - combo = view.device_combo_box - - # Replace existing items with a deterministic list - combo.clear() - combo.addItem("samx", 1) - combo.addItem("samy", 2) - combo.addItem("samz", 3) - combo.setCurrentText("samy") - - # Reverse the items - view._reverse_device_items() - - # Order should be reversed and selection preserved - assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"] - assert combo.currentText() == "samy" - - -def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch): - """ - Verify that _populate_preview_signals adds preview‑signal devices to the combo‑box - with the correct userData. - """ - view = create_widget(qtbot, Image, client=mocked_client) - - # Provide a deterministic fake device_manager with get_bec_signals - class _FakeDM: - def get_bec_signals(self, _filter): - return [ - ("eiger", "img", {"obj_name": "eiger_img"}), - ("async_device", "img2", {"obj_name": "async_device_img2"}), + _set_signal_config(mocked_client, "eiger", "img2d", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + view.client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img2d", + { + "obj_name": "img2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) - - initial_count = view.device_combo_box.count() - - view._populate_preview_signals() + view.device_name = "eiger" + view.device_entry = "img2d" - # Two new entries should have been added - assert view.device_combo_box.count() == initial_count + 2 + qtbot.wait(200) # Allow signal processing - # The first newly added item should carry tuple userData describing the device/signal - data = view.device_combo_box.itemData(initial_count) - assert isinstance(data, tuple) and data[0] == "eiger" + device_selection = view.toolbar.components.get_action("device_selection").widget + qtbot.waitUntil( + lambda: device_selection.device_combo_box.currentText() == "eiger" + and device_selection.signal_combo_box.currentText() == "img2d", + timeout=1000, + ) -def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch): - """ - Verify that _adjust_and_connect performs the full set-up: - - fills the combobox with preview signals, - - reverses their order, - - and resets the currentText to an empty string. - """ +def test_device_entry_syncs_from_toolbar(qtbot, mocked_client): view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2) + _set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2) - # Deterministic fake device_manager - class _FakeDM: - def get_bec_signals(self, _filter): - return [("eiger", "img", {"obj_name": "eiger_img"})] - - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) + view.device_name = "eiger" + view.device_entry = "img_a" - combo = view.device_combo_box - # Start from a clean state - combo.clear() - combo.addItem("", None) - combo.setCurrentText("") + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.blockSignals(True) + device_selection.signal_combo_box.setCurrentText("img_b") + device_selection.signal_combo_box.blockSignals(False) - # Execute the method under test - view._adjust_and_connect() + view._sync_device_entry_from_toolbar() - # Expect exactly two items: preview label followed by the empty default - assert combo.count() == 2 - # Because of the reversal, the preview label comes first - assert combo.itemText(0) == "eiger_img" - # Current selection remains empty - assert combo.currentText() == "" + assert view.device_entry == "img_b" diff --git a/tests/unit_tests/test_launch_window.py b/tests/unit_tests/test_launch_window.py index f23e51654..7967df65b 100644 --- a/tests/unit_tests/test_launch_window.py +++ b/tests/unit_tests/test_launch_window.py @@ -85,41 +85,30 @@ class PluginAutoUpdate(AutoUpdates): ... @pytest.mark.parametrize( - "connections, hide", + "connection_names, hide", [ - ({}, False), - ({"launcher": mock.MagicMock()}, False), - ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False), + ([], False), + (["launcher"], False), + (["launcher", "dock_area"], False), + (["launcher", "dock_area", "scan_progress"], False), + (["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], False), ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress": mock.MagicMock(), - }, - False, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - }, - False, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - "hover_widget": mock.MagicMock(), - }, + ["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"], True, ), ], ) -def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide): +def test_gui_server_turns_off_the_lights(bec_launch_window, connection_names, hide): + connections = {} + for name in connection_names: + conn = mock.MagicMock() + if name == "hover_widget": + conn.parent.return_value = None + conn.objectName.return_value = "HoverWidget" + else: + conn.parent.return_value = mock.MagicMock() + conn.objectName.return_value = bec_launch_window.objectName() + connections[name] = conn with ( mock.patch.object(bec_launch_window, "show") as mock_show, mock.patch.object(bec_launch_window, "activateWindow") as mock_activate_window, @@ -142,46 +131,35 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide): @pytest.mark.parametrize( - "connections, close_called", + "connection_names, close_called", [ - ({}, True), - ({"launcher": mock.MagicMock()}, True), - ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True), + ([], True), + (["launcher"], True), + (["launcher", "dock_area"], True), + (["launcher", "dock_area", "scan_progress"], True), + (["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], True), ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress": mock.MagicMock(), - }, - True, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - }, - True, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - "hover_widget": mock.MagicMock(), - }, + ["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"], False, ), ], ) -def test_launch_window_closes(bec_launch_window, connections, close_called): +def test_launch_window_closes(bec_launch_window, connection_names, close_called): """ Test that the close event is handled correctly based on the connections. If there are no connections or only the launcher connection, the window should close. If there are other connections, the window should hide instead of closing. """ + connections = {} + for name in connection_names: + conn = mock.MagicMock() + if name == "hover_widget": + conn.parent.return_value = None + conn.objectName.return_value = "HoverWidget" + else: + conn.parent.return_value = mock.MagicMock() + conn.objectName.return_value = bec_launch_window.objectName() + connections[name] = conn close_event = mock.MagicMock() with mock.patch.object( bec_launch_window.register, "list_all_connections", return_value=connections diff --git a/tests/unit_tests/test_macro_tree_widget.py b/tests/unit_tests/test_macro_tree_widget.py new file mode 100644 index 000000000..501836cb0 --- /dev/null +++ b/tests/unit_tests/test_macro_tree_widget.py @@ -0,0 +1,548 @@ +""" +Unit tests for the MacroTreeWidget. +""" + +from pathlib import Path + +import pytest +from qtpy.QtCore import QEvent, QModelIndex, Qt +from qtpy.QtGui import QMouseEvent + +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget + + +@pytest.fixture +def temp_macro_files(tmpdir): + """Create temporary macro files for testing.""" + macro_dir = Path(tmpdir) / "macros" + macro_dir.mkdir() + + # Create a simple macro file with functions + macro_file1 = macro_dir / "test_macros.py" + macro_file1.write_text( + ''' +def test_macro_function(): + """A test macro function.""" + return "test" + +def another_function(param1, param2): + """Another function with parameters.""" + return param1 + param2 + +class TestClass: + """This class should be ignored.""" + def method(self): + pass +''' + ) + + # Create another macro file + macro_file2 = macro_dir / "utils_macros.py" + macro_file2.write_text( + ''' +def utility_function(): + """A utility function.""" + pass + +def deprecated_function(): + """Old function.""" + return None +''' + ) + + # Create a file with no functions (should be ignored) + empty_file = macro_dir / "empty.py" + empty_file.write_text( + """ +# Just a comment +x = 1 +y = 2 +""" + ) + + # Create a file starting with underscore (should be ignored) + private_file = macro_dir / "_private.py" + private_file.write_text( + """ +def private_function(): + return "private" +""" + ) + + # Create a file with syntax errors + error_file = macro_dir / "error_file.py" + error_file.write_text( + """ +def broken_function( + # Missing closing parenthesis and colon + pass +""" + ) + + return macro_dir + + +@pytest.fixture +def macro_tree(qtbot, temp_macro_files): + """Create a MacroTreeWidget with test macro files.""" + widget = MacroTreeWidget() + widget.set_directory(str(temp_macro_files)) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestMacroTreeWidgetInitialization: + """Test macro tree widget initialization and basic functionality.""" + + def test_initialization(self, qtbot): + """Test that the macro tree widget initializes correctly.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Check basic properties + assert widget.tree is not None + assert widget.model is not None + assert widget.delegate is not None + assert widget.directory is None + + # Check that tree is configured properly + assert widget.tree.isHeaderHidden() + assert widget.tree.rootIsDecorated() + assert not widget.tree.editTriggers() + + def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files): + """Test setting a valid directory path.""" + assert macro_tree.directory == str(temp_macro_files) + + # Check that files were loaded + assert macro_tree.model.rowCount() > 0 + + # Should have 2 files (test_macros.py and utils_macros.py) + # empty.py and _private.py should be filtered out + expected_files = ["test_macros", "utils_macros"] + actual_files = [] + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + actual_files.append(item.text()) + + # Sort for consistent comparison + actual_files.sort() + expected_files.sort() + + for expected in expected_files: + assert expected in actual_files + + def test_set_directory_with_invalid_path(self, qtbot): + """Test setting an invalid directory path.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory("/nonexistent/path") + + # Should handle gracefully + assert widget.directory == "/nonexistent/path" + assert widget.model.rowCount() == 0 + + def test_set_directory_with_none(self, qtbot): + """Test setting directory to None.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory(None) + + # Should handle gracefully + assert widget.directory is None + assert widget.model.rowCount() == 0 + + +class TestMacroFunctionParsing: + """Test macro function parsing and AST functionality.""" + + def test_extract_functions_from_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + functions = macro_tree._extract_functions_from_file(test_file) + + # Should extract 2 functions, not the class method + assert len(functions) == 2 + assert "test_macro_function" in functions + assert "another_function" in functions + assert "method" not in functions # Class methods should be excluded + + # Check function details + test_func = functions["test_macro_function"] + assert test_func["line_number"] == 2 # First function starts at line 2 + assert "A test macro function" in test_func["docstring"] + + def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with no functions.""" + empty_file = temp_macro_files / "empty.py" + functions = macro_tree._extract_functions_from_file(empty_file) + + assert len(functions) == 0 + + def test_extract_functions_from_invalid_file(self, macro_tree): + """Test extracting functions from a non-existent file.""" + nonexistent_file = Path("/nonexistent/file.py") + functions = macro_tree._extract_functions_from_file(nonexistent_file) + + assert len(functions) == 0 + + def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with syntax errors.""" + error_file = temp_macro_files / "error_file.py" + functions = macro_tree._extract_functions_from_file(error_file) + + # Should return empty dict on syntax error + assert len(functions) == 0 + + def test_create_file_item(self, macro_tree, temp_macro_files): + """Test creating a file item from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + file_item = macro_tree._create_file_item(test_file) + + assert file_item is not None + assert file_item.text() == "test_macros" + assert file_item.rowCount() == 2 # Should have 2 function children + + # Check file data + file_data = file_item.data(Qt.ItemDataRole.UserRole) + assert file_data["type"] == "file" + assert file_data["file_path"] == str(test_file) + + # Check function children + func_names = [] + for row in range(file_item.rowCount()): + child = file_item.child(row) + func_names.append(child.text()) + + # Check function data + func_data = child.data(Qt.ItemDataRole.UserRole) + assert func_data["type"] == "function" + assert func_data["file_path"] == str(test_file) + assert "function_name" in func_data + assert "line_number" in func_data + + assert "test_macro_function" in func_names + assert "another_function" in func_names + + def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files): + """Test that files starting with underscore are ignored.""" + private_file = temp_macro_files / "_private.py" + file_item = macro_tree._create_file_item(private_file) + + assert file_item is None + + def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files): + """Test that files with no functions return None.""" + empty_file = temp_macro_files / "empty.py" + file_item = macro_tree._create_file_item(empty_file) + + assert file_item is None + + +class TestMacroTreeInteractions: + """Test macro tree widget interactions and signals.""" + + def test_item_click_on_function(self, macro_tree, qtbot): + """Test clicking on a function item.""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a function item + file_item = macro_tree.model.item(0) # First file + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) # First function + func_index = func_item.index() + + # Simulate click + macro_tree._on_item_clicked(func_index) + + # Check signal was emitted + assert len(macro_selected_signals) == 1 + function_name, file_path = macro_selected_signals[0] + assert function_name is not None + assert file_path is not None + assert file_path.endswith(".py") + + def test_item_click_on_file(self, macro_tree, qtbot): + """Test clicking on a file item (should not emit signal).""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a file item + file_item = macro_tree.model.item(0) + if file_item: + file_index = file_item.index() + + # Simulate click + macro_tree._on_item_clicked(file_index) + + # Should not emit signal for file items + assert len(macro_selected_signals) == 0 + + def test_item_double_click_on_function(self, macro_tree, qtbot): + """Test double-clicking on a function item.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Simulate double-click + macro_tree._on_item_double_clicked(func_index) + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + def test_hover_events(self, macro_tree, qtbot): + """Test mouse hover events and action button visibility.""" + # Get the tree view and its viewport + tree_view = macro_tree.tree + viewport = tree_view.viewport() + + # Initially, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + # Find a function item to hover over + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Get the position of the function item + rect = tree_view.visualRect(func_index) + pos = rect.center() + + # Simulate a mouse move event over the item + mouse_event = QMouseEvent( + QEvent.Type.MouseMove, + pos, + tree_view.mapToGlobal(pos), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + # Send the event to the viewport + macro_tree.eventFilter(viewport, mouse_event) + qtbot.wait(100) + + # Now, the hover index should be set + assert macro_tree.delegate.hovered_index.isValid() + assert macro_tree.delegate.hovered_index == func_index + + # Simulate mouse leaving the viewport + leave_event = QEvent(QEvent.Type.Leave) + macro_tree.eventFilter(viewport, leave_event) + qtbot.wait(100) + + # After leaving, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + def test_macro_open_action(self, macro_tree, qtbot): + """Test the macro open action functionality.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item and set it as hovered + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Set the delegate's hovered index and current macro info + macro_tree.delegate.set_hovered_index(func_index) + func_data = func_item.data(Qt.ItemDataRole.UserRole) + macro_tree.delegate.current_macro_info = func_data + + # Trigger the open action + macro_tree._on_macro_open_requested() + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + +class TestMacroTreeRefresh: + """Test macro tree refresh functionality.""" + + def test_refresh(self, macro_tree, temp_macro_files): + """Test refreshing the entire tree.""" + # Get initial count + initial_count = macro_tree.model.rowCount() + + # Add a new macro file + new_file = temp_macro_files / "new_macros.py" + new_file.write_text( + ''' +def new_function(): + """A new function.""" + return "new" +''' + ) + + # Refresh the tree + macro_tree.refresh() + + # Should have one more file + assert macro_tree.model.rowCount() == initial_count + 1 + + def test_refresh_file_item(self, macro_tree, temp_macro_files): + """Test refreshing a single file item.""" + # Find the test_macros.py file + test_file_path = str(temp_macro_files / "test_macros.py") + + # Get initial function count + initial_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + initial_functions.append(child.text()) + break + + # Modify the file to add a new function + with open(test_file_path, "a") as f: + f.write( + ''' + +def newly_added_function(): + """A newly added function.""" + return "added" +''' + ) + + # Refresh just this file + macro_tree.refresh_file_item(test_file_path) + + # Check that the new function was added + updated_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + updated_functions.append(child.text()) + break + + # Should have the new function + assert len(updated_functions) == len(initial_functions) + 1 + assert "newly_added_function" in updated_functions + + def test_refresh_nonexistent_file(self, macro_tree): + """Test refreshing a non-existent file.""" + # Should handle gracefully without crashing + macro_tree.refresh_file_item("/nonexistent/file.py") + + # Tree should remain unchanged + assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash + + def test_expand_collapse_all(self, macro_tree, qtbot): + """Test expand/collapse all functionality.""" + # Initially should be expanded + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + # Items with children should be expanded after initial load + if item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + # Collapse all + macro_tree.collapse_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert not macro_tree.tree.isExpanded(item.index()) + + # Expand all + macro_tree.expand_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + +class TestMacroItemDelegate: + """Test the custom macro item delegate functionality.""" + + def test_delegate_action_management(self, qtbot): + """Test adding and clearing delegate actions.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Should have at least one default action (open) + assert len(widget.delegate.macro_actions) >= 1 + + # Add a custom action + custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget) + widget.add_macro_action(custom_action.action) + + # Should have the additional action + assert len(widget.delegate.macro_actions) >= 2 + + # Clear actions + widget.clear_actions() + + # Should be empty + assert len(widget.delegate.macro_actions) == 0 + + def test_delegate_hover_index_management(self, qtbot): + """Test hover index management in the delegate.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Initially no hover + assert not widget.delegate.hovered_index.isValid() + + # Create a fake index + fake_index = widget.model.createIndex(0, 0) + + # Set hover + widget.delegate.set_hovered_index(fake_index) + assert widget.delegate.hovered_index == fake_index + + # Clear hover + widget.delegate.set_hovered_index(QModelIndex()) + assert not widget.delegate.hovered_index.isValid() diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py new file mode 100644 index 000000000..3d3a42f14 --- /dev/null +++ b/tests/unit_tests/test_main_app.py @@ -0,0 +1,111 @@ +import pytest +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.main_app import BECMainApp +from bec_widgets.applications.views.view import ViewBase + +from .client_mocks import mocked_client + +ANIM_TEST_DURATION = 60 # ms + + +@pytest.fixture +def viewbase(qtbot): + v = ViewBase(content=QWidget()) + qtbot.addWidget(v) + qtbot.waitExposed(v) + yield v + + +# Spy views for testing enter/exit hooks and veto logic +class SpyView(ViewBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.enter_calls = 0 + self.exit_calls = 0 + + def on_enter(self) -> None: + self.enter_calls += 1 + + def on_exit(self) -> bool: + self.exit_calls += 1 + return True + + +class SpyVetoView(SpyView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.allow_exit = False + + def on_exit(self) -> bool: + self.exit_calls += 1 + return bool(self.allow_exit) + + +@pytest.fixture +def app_with_spies(qtbot, mocked_client): + app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False) + qtbot.addWidget(app) + qtbot.waitExposed(app) + + app.add_section("Tests", id="tests") + + v1 = SpyView(id="v1", title="V1") + v2 = SpyView(id="v2", title="V2") + vv = SpyVetoView(id="vv", title="VV") + + app.add_view(icon="widgets", title="View 1", id="v1", widget=v1, mini_text="v1") + app.add_view(icon="widgets", title="View 2", id="v2", widget=v2, mini_text="v2") + app.add_view(icon="widgets", title="Veto View", id="vv", widget=vv, mini_text="vv") + + # Start from dock_area (default) to avoid extra enter/exit counts on spies + assert app.stack.currentIndex() == app._view_index["dock_area"] + return app, v1, v2, vv + + +def test_viewbase_initializes(viewbase): + assert viewbase.on_enter() is None + assert viewbase.on_exit() is True + + +def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot): + app, v1, v2, _ = app_with_spies + + app.set_current("v1") + qtbot.wait(10) + assert v1.enter_calls == 1 + + app.set_current("v2") + qtbot.wait(10) + assert v1.exit_calls == 1 + assert v2.enter_calls == 1 + + app.set_current("v1") + qtbot.wait(10) + assert v2.exit_calls == 1 + assert v1.enter_calls == 2 + + +def test_on_exit_veto_prevents_switch_until_allowed(app_with_spies, qtbot): + app, v1, v2, vv = app_with_spies + + # Move to veto view first + app.set_current("vv") + qtbot.wait(10) + assert vv.enter_calls == 1 + + # Attempt to leave veto view -> should veto + app.set_current("v1") + qtbot.wait(10) + assert vv.exit_calls == 1 + # Still on veto view because veto returned False + assert app.stack.currentIndex() == app._view_index["vv"] + + # Allow exit and try again + vv.allow_exit = True + app.set_current("v1") + qtbot.wait(10) + + # Now the switch should have happened, and v1 received on_enter + assert app.stack.currentIndex() == app._view_index["v1"] + assert v1.enter_calls >= 1 diff --git a/tests/unit_tests/test_modular_toolbar.py b/tests/unit_tests/test_modular_toolbar.py index e9238f7c9..307485d0a 100644 --- a/tests/unit_tests/test_modular_toolbar.py +++ b/tests/unit_tests/test_modular_toolbar.py @@ -16,6 +16,7 @@ WidgetAction, ) from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.splitter import ResizableSpacer from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -612,3 +613,129 @@ def test_remove_nonexistent_bundle(toolbar_fixture): with pytest.raises(KeyError) as excinfo: toolbar.remove_bundle("nonexistent_bundle") excinfo.match("Bundle with name 'nonexistent_bundle' does not exist.") + + +def _find_splitter_widget(toolbar: ModularToolBar) -> ResizableSpacer: + for action in toolbar.actions(): + widget = toolbar.widgetForAction(action) + if isinstance(widget, ResizableSpacer): + return widget + raise AssertionError("ResizableSpacer not found in toolbar actions.") + + +def test_add_splitter_auto_orientation(toolbar_fixture, qtbot): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle = toolbar.new_bundle("splitter_bundle") + bundle.add_action("combo_action") + bundle.add_splitter(name="splitter", target_widget=combo, min_width=80) + + toolbar.show_bundles(["splitter_bundle"]) + qtbot.wait(50) + + splitter_widget = _find_splitter_widget(toolbar) + if toolbar.orientation() == Qt.Horizontal: + assert splitter_widget.orientation == "horizontal" + assert splitter_widget.cursor().shape() == Qt.CursorShape.SplitHCursor + else: + assert splitter_widget.orientation == "vertical" + assert splitter_widget.cursor().shape() == Qt.CursorShape.SplitVCursor + + +def test_separator_hidden_next_to_splitter(toolbar_fixture, material_icon_action): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle_with_splitter = toolbar.new_bundle("bundle_with_splitter") + bundle_with_splitter.add_action("combo_action") + bundle_with_splitter.add_splitter(name="splitter", target_widget=combo, min_width=80) + + toolbar.components.add_safe("icon_action", material_icon_action) + bundle_next = toolbar.new_bundle("bundle_next") + bundle_next.add_action("icon_action") + + toolbar.show_bundles(["bundle_with_splitter", "bundle_next"]) + + actions = toolbar.actions() + splitter_index = None + for idx, action in enumerate(actions): + if isinstance(toolbar.widgetForAction(action), ResizableSpacer): + splitter_index = idx + break + assert splitter_index is not None + + separator_action = actions[splitter_index + 1] + assert separator_action.isSeparator() + assert not separator_action.isVisible() + + +def test_splitter_action_set_target_widget_after_show(toolbar_fixture, qtbot): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle = toolbar.new_bundle("splitter_bundle") + bundle.add_action("combo_action") + bundle.add_splitter(name="splitter", min_width=80, max_width=160) + + toolbar.show_bundles(["splitter_bundle"]) + qtbot.wait(200) + + splitter_action = toolbar.components.get_action("splitter") + splitter_action.set_target_widget(combo) + + splitter_widget = _find_splitter_widget(toolbar) + if hasattr(splitter_widget, "get_target_widget"): + assert splitter_widget.get_target_widget() is combo + if splitter_widget.orientation == "horizontal": + assert 80 <= combo.width() <= 160 + else: + assert 80 <= combo.height() <= 160 + + +@pytest.mark.parametrize( + "orientation, delta", [("horizontal", QPoint(40, 0)), ("vertical", QPoint(0, 40))] +) +def test_splitter_mouse_events_resize_target(qtbot, orientation, delta): + from qtpy.QtWidgets import QVBoxLayout + + parent = QWidget() + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 0, 0) + + target = QComboBox() + target.addItems(["One", "Two", "Three"]) + layout.addWidget(target) + + splitter = ResizableSpacer( + parent=parent, + orientation=orientation, + initial_width=10, + min_target_size=60, + max_target_size=200, + target_widget=target, + ) + layout.addWidget(splitter) + + qtbot.addWidget(parent) + parent.show() + qtbot.waitExposed(parent) + + start_size = target.width() if orientation == "horizontal" else target.height() + + qtbot.mousePress(splitter, Qt.LeftButton, pos=splitter.rect().center()) + qtbot.mouseMove(splitter, splitter.rect().center() + delta) + qtbot.mouseRelease(splitter, Qt.LeftButton, pos=splitter.rect().center() + delta) + + end_size = target.width() if orientation == "horizontal" else target.height() + assert end_size != start_size + assert 60 <= end_size <= 200 diff --git a/tests/unit_tests/test_monaco_dock.py b/tests/unit_tests/test_monaco_dock.py new file mode 100644 index 000000000..0a1d6b88b --- /dev/null +++ b/tests/unit_tests/test_monaco_dock.py @@ -0,0 +1,425 @@ +import os +from typing import Generator +from unittest import mock + +import pytest +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +from .client_mocks import mocked_client + + +@pytest.fixture +def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]: + """Create a MonacoDock for testing.""" + # Mock the macros functionality + mocked_client.macros = mock.MagicMock() + mocked_client.macros._update_handler = mock.MagicMock() + mocked_client.macros._update_handler.get_macros_from_file.return_value = {} + mocked_client.macros._update_handler.get_existing_macros.return_value = {} + + widget = MonacoDock(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestFocusEditor: + def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock): + """Test that last_focused_editor is initially None.""" + assert monaco_dock.last_focused_editor is not None + + def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test setting last_focused_editor when an editor is focused.""" + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) # Wait for the editor to be fully set up + + assert monaco_dock.last_focused_editor is not None + + def test_last_focused_editor_updates_on_focus_change( + self, qtbot, monaco_dock: MonacoDock, tmpdir + ): + """Test that last_focused_editor updates when focus changes.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + assert editor1 != editor2 + assert editor2 is not None + + def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that opening an already open file simply switches focus to it.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + # Re-open file1 + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1_again = monaco_dock.last_focused_editor + + assert editor1 == editor1_again + assert editor1 != editor2 + assert editor2 is not None + + +class TestSaveFiles: + def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving an existing file that is not a macro.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Verify the editor is marked as modified + assert editor_widget.modified + + # Save the file + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName" + ) as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + monaco_dock.save_file() + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a file with macros scope updates macro handler.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Mock macro validation to return True (valid) + with mock.patch.object(monaco_dock, "_validate_macros", return_value=True): + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") # User cancels + # Save the file (should save to existing file, not open dialog) + monaco_dock.save_file() + qtbot.wait(100) + + # Verify macro update methods were called + monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with( + str(file_path) + ) + monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with( + str(file_path) + ) + + def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a macro file with invalid content shows warning.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content to invalid macro + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("exec('print(hello)')") # Invalid macro content + qtbot.wait(100) + + # Mock QMessageBox to capture warning + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning" + ) as mock_warning: + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify validation was called and warning was shown + mock_warning.assert_called_once() + + # Verify file was not saved (content should remain original) + saved_content = file_path.read() + assert saved_content == "def test_function(): pass" + + def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As functionality creates a new file.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('New file content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName + new_file_path = str(tmpdir.join("new_file.py")) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (new_file_path, "Python files (*.py)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify new file was created + assert os.path.exists(new_file_path) + with open(new_file_path, "r", encoding="utf-8") as f: + content = f.read() + assert content == 'print("New file content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + # Verify current_file was updated + assert editor_widget.current_file == new_file_path + + def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As automatically adds .py extension if none provided.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Test content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName to return path without extension + file_path_no_ext = str(tmpdir.join("test_file")) + expected_path = file_path_no_ext + ".py" + + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (file_path_no_ext, "All files (*)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify file was created with .py extension + assert os.path.exists(expected_path) + assert editor_widget.current_file == expected_path + + def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock): + """Test save_file handles case when no editor is focused.""" + # Set last_focused_editor to None + with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None): + # Attempt to save should not raise exception + monaco_dock.save_file() + + def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir): + """Test that macro_file_updated signal is emitted when saving macro files.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Connect signal to capture emission + signal_emitted = [] + monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path)) + + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify signal was emitted + assert len(signal_emitted) == 1 + assert signal_emitted[0] == str(file_path) + + def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that closing a modified file dock asks to save changes.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Mock QMessageBox to simulate user clicking 'Save' + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.StandardButton.Yes + + # Mock QFileDialog.getSaveFileName + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + + # Close the dock; sadly, calling close() alone does not trigger the closeRequested signal + # It is only triggered if the mouse is on top of the tab close button, so we directly call the handler + monaco_dock._on_editor_close_requested( + monaco_dock.last_focused_editor, editor_widget + ) + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + +class TestSignatureHelp: + def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help signal is emitted correctly.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data + signature_data = { + "signatures": [ + { + "label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)", + "documentation": { + "value": "Print objects to the text stream file, separated by sep and followed by end." + }, + } + ], + "activeSignature": 0, + "activeParameter": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct markdown format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature + assert "Print objects to the text stream file" in emitted_signature + + def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock): + """Test signature help with empty signatures.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with no signatures + signature_data = {"signatures": []} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify empty string was emitted + assert len(signature_emitted) == 1 + assert signature_emitted[0] == "" + + def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is missing.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data without documentation + signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with just the function signature + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + + def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is a string instead of dict.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with string documentation + signature_data = { + "signatures": [ + {"label": "function_name(param)", "documentation": "Simple string documentation"} + ], + "activeSignature": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + assert "Simple string documentation" in emitted_signature + + def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help is connected when creating new editors.""" + # Create a new editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + + # Verify the signal connection exists by checking connected signals + # We do this by mocking the signal and verifying the connection + with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler: + # Simulate signature help trigger from the editor + editor_widget.editor.signature_help_triggered.emit({"signatures": []}) + qtbot.wait(100) + + # Verify the handler was called + mock_handler.assert_called_once() diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 149f4d75a..f0b39506b 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -1,11 +1,20 @@ +from unittest import mock + import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +from .client_mocks import mocked_client +from .test_scan_control import available_scans_message @pytest.fixture -def monaco_widget(qtbot): - widget = MonacoWidget() +def monaco_widget(qtbot, mocked_client): + widget = MonacoWidget(client=mocked_client) + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -37,3 +46,75 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot): monaco_widget.set_text("Attempting to change text") qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000) assert monaco_widget.get_text() == "Attempting to change text" + + +def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can show the scan control dialog. + """ + + with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog: + monaco_widget._show_scan_control_dialog() + mock_run_dialog.assert_called_once() + + +def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client): + """ + Test that the MonacoWidget can get scan control code from the dialog. + """ + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) + + scan_control_dialog = ScanControlDialog(client=mocked_client) + qtbot.addWidget(scan_control_dialog) + qtbot.waitExposed(scan_control_dialog) + qtbot.wait(300) + + scan_control = scan_control_dialog.scan_control + scan_name = "grid_scan" + kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2} + args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20} + args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10} + mock_slot = mock.MagicMock() + + scan_control.scan_args.connect(mock_slot) + + scan_control.comboBox_scan_selection.setCurrentText(scan_name) + + # Ensure there are two rows in the arg_box + current_rows = scan_control.arg_box.count_arg_rows() + required_rows = 2 + while current_rows < required_rows: + scan_control.arg_box.add_widget_bundle() + current_rows += 1 + + # Set kwargs in the UI + for kwarg_box in scan_control.kwarg_boxes: + for widget in kwarg_box.widgets: + if widget.arg_name in kwargs: + WidgetIO.set_value(widget, kwargs[widget.arg_name]) + + # Set args in the UI for both rows + arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets + num_columns = len(scan_control.arg_box.inputs) + num_rows = int(len(arg_widgets) / num_columns) + assert num_rows == required_rows # We expect 2 rows for grid_scan + + # Set values for first row + for i in range(num_columns): + widget = arg_widgets[i] + arg_name = widget.arg_name + if arg_name in args_row1: + WidgetIO.set_value(widget, args_row1[arg_name]) + + # Set values for second row + for i in range(num_columns): + widget = arg_widgets[num_columns + i] # Next row + arg_name = widget.arg_name + if arg_name in args_row2: + WidgetIO.set_value(widget, args_row2[arg_name]) + + scan_control_dialog.accept() + out = scan_control_dialog.get_scan_code() + + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None, metadata={'sample_name': ''})" + assert out == expected_code diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py index 277b3be17..4e296f63d 100644 --- a/tests/unit_tests/test_motor_map_next_gen.py +++ b/tests/unit_tests/test_motor_map_next_gen.py @@ -1,5 +1,4 @@ -import numpy as np -import pyqtgraph as pg +from qtpy.QtTest import QSignalSpy from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from tests.unit_tests.client_mocks import mocked_client @@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client): # Verify toolbar bundle was created during initialization motor_selection = mm.toolbar.components.get_action("motor_selection") - motor_selection.motor_x.setCurrentText("samx") - motor_selection.motor_y.setCurrentText("samy") + motor_selection.widget.motor_x.setCurrentText("samx") + motor_selection.widget.motor_y.setCurrentText("samy") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samy" - motor_selection.motor_y.setCurrentText("samz") + motor_selection.widget.motor_y.setCurrentText("samz") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samz" +def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client): + """Ensure set_motors updates both comboboxes without emitting change signals.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged) + spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged) + + motor_selection.set_motors("samx", "samy") + + assert motor_selection.motor_x.currentText() == "samx" + assert motor_selection.motor_y.currentText() == "samy" + assert spy_x.count() == 0 + assert spy_y.count() == 0 + + +def test_motor_properties_partial_then_complete_map(qtbot, mocked_client): + """Setting x then y via properties should map once both are valid.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + + spy = QSignalSpy(mm.property_changed) + mm.x_motor = "samx" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name is None + assert mm._trace is None # map not triggered yet + assert spy.at(0) == ["x_motor", "samx"] + + mm.y_motor = "samy" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name == "samy" + assert mm._trace is not None # map called once both valid + assert spy.at(1) == ["y_motor", "samy"] + assert len(mm._buffer["x"]) == 1 + assert len(mm._buffer["y"]) == 1 + + +def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client): + """_set_motor_name should emit property changes and sync toolbar widgets.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy = QSignalSpy(mm.property_changed) + mm._set_motor_name("x", "samx") + + assert mm.config.x_motor.name == "samx" + assert motor_selection.motor_x.currentText() == "samx" + assert spy.at(0) == ["x_motor", "samx"] + + # Calling with same name should be a no-op + initial_count = spy.count() + mm._set_motor_name("x", "samx") + assert spy.count() == initial_count + + def test_motor_map_settings_dialog(qtbot, mocked_client): """Test the settings dialog for the motor map.""" mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True) diff --git a/tests/unit_tests/test_plugin_utils.py b/tests/unit_tests/test_plugin_utils.py index 5650b5315..ef9c456e5 100644 --- a/tests/unit_tests/test_plugin_utils.py +++ b/tests/unit_tests/test_plugin_utils.py @@ -8,5 +8,5 @@ def test_client_generator_classes(): assert "Image" in connector_cls_names assert "Waveform" in connector_cls_names - assert "BECDockArea" in plugins + assert "MotorMap" in plugins assert "NonExisting" not in plugins diff --git a/tests/unit_tests/test_reveal_animator.py b/tests/unit_tests/test_reveal_animator.py new file mode 100644 index 000000000..5704eb6ef --- /dev/null +++ b/tests/unit_tests/test_reveal_animator.py @@ -0,0 +1,128 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup +from qtpy.QtWidgets import QLabel + +from bec_widgets.applications.navigation_centre.reveal_animator import RevealAnimator + +ANIM_TEST_DURATION = 50 # ms + + +@pytest.fixture +def label(qtbot): + w = QLabel("Reveal Label") + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def _run_group(group: QParallelAnimationGroup, qtbot, duration_ms: int): + group.start() + qtbot.wait(duration_ms + 100) + + +def test_immediate_collapsed_then_revealed(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + # Initially collapsed + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + # Snap to revealed + anim.set_immediate(True) + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + +def test_reveal_then_collapse_with_animation(label, qtbot): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + # Collapse using the SAME group; do not re-add animations to avoid deletion + anim.setup(False) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + +@pytest.mark.parametrize( + "flags", + [ + dict(animate_opacity=False, animate_width=True, animate_height=True), + dict(animate_opacity=True, animate_width=False, animate_height=True), + dict(animate_opacity=True, animate_width=True, animate_height=False), + ], +) +def test_partial_flags_respectively_disable_properties(label, qtbot, flags): + # Establish initial state + label.setMaximumWidth(123) + label.setMaximumHeight(456) + + anim = RevealAnimator(label, duration=10, initially_revealed=False, **flags) + + # Record baseline values for disabled properties + baseline_opacity = anim.fx.opacity() + baseline_w = label.maximumWidth() + baseline_h = label.maximumHeight() + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + + if flags.get("animate_opacity", True): + assert anim.fx.opacity() == pytest.approx(1.0) + else: + # Opacity should remain unchanged + assert anim.fx.opacity() == pytest.approx(baseline_opacity) + + if flags.get("animate_width", True): + assert label.maximumWidth() == max(sh.width(), 1) + else: + assert label.maximumWidth() == baseline_w + + if flags.get("animate_height", True): + assert label.maximumHeight() == max(sh.height(), 1) + else: + assert label.maximumHeight() == baseline_h + + +def test_animations_list_and_order(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION) + lst = anim.animations() + # All should be present and in defined order: opacity, height, width + names = [a.propertyName() for a in lst] + assert names == [b"opacity", b"maximumHeight", b"maximumWidth"] + + +@pytest.mark.parametrize( + "flags,expected", + [ + (dict(animate_opacity=False), [b"maximumHeight", b"maximumWidth"]), + (dict(animate_width=False), [b"opacity", b"maximumHeight"]), + (dict(animate_height=False), [b"opacity", b"maximumWidth"]), + (dict(animate_opacity=False, animate_width=False, animate_height=True), [b"maximumHeight"]), + (dict(animate_opacity=False, animate_width=True, animate_height=False), [b"maximumWidth"]), + (dict(animate_opacity=True, animate_width=False, animate_height=False), [b"opacity"]), + (dict(animate_opacity=False, animate_width=False, animate_height=False), []), + ], +) +def test_animations_respects_flags(label, flags, expected): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, **flags) + names = [a.propertyName() for a in anim.animations()] + assert names == expected diff --git a/tests/unit_tests/test_ring_progress_bar.py b/tests/unit_tests/test_ring_progress_bar.py index 9ff95baf6..e858e80da 100644 --- a/tests/unit_tests/test_ring_progress_bar.py +++ b/tests/unit_tests/test_ring_progress_bar.py @@ -1,13 +1,14 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +import json + import pytest from bec_lib.endpoints import MessageEndpoints from pydantic import ValidationError +from qtpy.QtGui import QColor from bec_widgets.utils import Colors -from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar -from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConnections, RingConfig -from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBarConfig +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar from .client_mocks import mocked_client @@ -29,176 +30,117 @@ def test_bar_init(ring_progress_bar): assert ring_progress_bar.gui_id == ring_progress_bar.config.gui_id -def test_config_validation_num_of_bars(): - config = RingProgressBarConfig(num_bars=100, min_num_bars=1, max_num_bars=10) - - assert config.num_bars == 10 - - -def test_config_validation_num_of_ring_error(): - ring_config_0 = RingConfig(index=0) - ring_config_1 = RingConfig(index=1) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=1) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "different number of configs" - assert "Length of rings configuration (2) does not match the number of bars (1)." in str( - excinfo.value - ) - - -def test_config_validation_ring_indices_wrong_order(): - ring_config_0 = RingConfig(index=2) - ring_config_1 = RingConfig(index=5) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "wrong indices" - assert ( - "Indices of ring configurations must be unique and in order from 0 to num_bars 2." - in str(excinfo.value) - ) - - -def test_config_validation_ring_same_indices(): - ring_config_0 = RingConfig(index=0) - ring_config_1 = RingConfig(index=0) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "wrong indices" - assert ( - "Indices of ring configurations must be unique and in order from 0 to num_bars 2." - in str(excinfo.value) - ) - - -def test_config_validation_invalid_colormap(): - with pytest.raises(ValueError) as excinfo: - RingProgressBarConfig(color_map="crazy_colors") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported colormap" - assert "Colormap 'crazy_colors' not found in the current installation of pyqtgraph" in str( - excinfo.value - ) - - -def test_ring_connection_endpoint_validation(): - with pytest.raises(ValueError) as excinfo: - ProgressbarConnections(slot="on_scan_progress", endpoint="non_existing") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported endpoint" - assert ( - "For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'." - in str(excinfo.value) - ) - - with pytest.raises(ValueError) as excinfo: - ProgressbarConnections(slot="on_device_readback", endpoint="non_existing") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported endpoint" - assert ( - "For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'." - in str(excinfo.value) - ) - - -def test_bar_add_number_of_bars(ring_progress_bar): - assert ring_progress_bar.config.num_bars == 1 - - ring_progress_bar.set_number_of_bars(5) - assert ring_progress_bar.config.num_bars == 5 - - ring_progress_bar.set_number_of_bars(2) - assert ring_progress_bar.config.num_bars == 2 - - -def test_add_remove_bars_individually(ring_progress_bar): - ring_progress_bar.add_ring() +def test_rpb_center_label(ring_progress_bar): + test_text = "Center Label" + ring_progress_bar.set_center_label(test_text) + assert ring_progress_bar.center_label == test_text + assert ring_progress_bar.ring_progress_bar.center_label.text() == test_text + + +def test_add_ring(qtbot, ring_progress_bar): + ring_progress_bar.show() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == len(ring_progress_bar.rings) ring_progress_bar.add_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars + 1 + assert len(ring_progress_bar.rings) == initial_num_bars + 1 + qtbot.wait(200) - assert ring_progress_bar.config.num_bars == 3 - assert len(ring_progress_bar.config.rings) == 3 - ring_progress_bar.remove_ring(1) - assert ring_progress_bar.config.num_bars == 2 - assert len(ring_progress_bar.config.rings) == 2 - assert ring_progress_bar.rings[0].config.index == 0 - assert ring_progress_bar.rings[1].config.index == 1 +def test_remove_ring(ring_progress_bar): + ring_progress_bar.add_ring() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == len(ring_progress_bar.rings) + ring_progress_bar.remove_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars - 1 + assert len(ring_progress_bar.rings) == initial_num_bars - 1 -def test_bar_set_value(ring_progress_bar): - ring_progress_bar.set_number_of_bars(5) +def test_remove_ring_no_bars(ring_progress_bar): + # Remove all rings first + while ring_progress_bar.ring_progress_bar.num_bars > 0: + ring_progress_bar.remove_ring() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == 0 + # Attempt to remove a ring when there are none + ring_progress_bar.remove_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars + assert len(ring_progress_bar.rings) == initial_num_bars - assert ring_progress_bar.config.num_bars == 5 - assert len(ring_progress_bar.config.rings) == 5 - assert len(ring_progress_bar.rings) == 5 - ring_progress_bar.set_value([10, 20, 30, 40, 50]) - ring_values = [ring.config.value for ring in ring_progress_bar.rings] - assert ring_values == [10, 20, 30, 40, 50] +def test_bar_set_value(ring_progress_bar): + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - # update just one bar - ring_progress_bar.set_value(90, 1) + ring_progress_bar.rings[0].set_value(10) + ring_progress_bar.rings[1].set_value(20) ring_values = [ring.config.value for ring in ring_progress_bar.rings] - assert ring_values == [10, 90, 30, 40, 50] + assert ring_values == [10, 20] def test_bar_set_precision(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - assert ring_progress_bar.config.num_bars == 3 - assert len(ring_progress_bar.config.rings) == 3 + assert ring_progress_bar.ring_progress_bar.num_bars == 3 assert len(ring_progress_bar.rings) == 3 - ring_progress_bar.set_precision(2) + # Set precision for all rings + for ring in ring_progress_bar.rings: + ring.set_precision(2) ring_precision = [ring.config.precision for ring in ring_progress_bar.rings] assert ring_precision == [2, 2, 2] - ring_progress_bar.set_value([10.1234, 20.1234, 30.1234]) + # Set values + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_value([10.1234, 20.1234, 30.1234][i]) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [10.12, 20.12, 30.12] - ring_progress_bar.set_precision(4, 1) + # Set precision for ring at index 1 + ring_progress_bar.rings[1].set_precision(4) ring_precision = [ring.config.precision for ring in ring_progress_bar.rings] assert ring_precision == [2, 4, 2] - ring_progress_bar.set_value([10.1234, 20.1234, 30.1234]) + # Set values again + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_value([10.1234, 20.1234, 30.1234][i]) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [10.12, 20.1234, 30.12] def test_set_min_max_value(ring_progress_bar): - ring_progress_bar.set_number_of_bars(2) + # Add 2 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - ring_progress_bar.set_min_max_values(0, 10) + # Set min/max values for all rings + for ring in ring_progress_bar.rings: + ring.set_min_max_values(0, 10) ring_min_values = [ring.config.min_value for ring in ring_progress_bar.rings] ring_max_values = [ring.config.max_value for ring in ring_progress_bar.rings] assert ring_min_values == [0, 0] assert ring_max_values == [10, 10] - ring_progress_bar.set_value([5, 15]) + # Set values + ring_progress_bar.rings[0].set_value(5) + ring_progress_bar.rings[1].set_value(15) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [5, 10] def test_setup_colors_from_colormap(ring_progress_bar): - ring_progress_bar.set_number_of_bars(5) - ring_progress_bar.set_colors_from_map("viridis", "RGB") + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 5, "RGB") converted_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] - ring_config_colors = [ring.config.color for ring in ring_progress_bar.rings] + ring_config_colors = [QColor(ring.config.color).getRgb() for ring in ring_progress_bar.rings] assert expected_colors == converted_colors assert ring_config_colors == expected_colors @@ -206,13 +148,15 @@ def test_setup_colors_from_colormap(ring_progress_bar): def get_colors_from_rings(rings): converted_colors = [ring.color.getRgb() for ring in rings] - ring_config_colors = [ring.config.color for ring in rings] + ring_config_colors = [QColor(ring.config.color).getRgb() for ring in rings] return converted_colors, ring_config_colors def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): - ring_progress_bar.set_number_of_bars(2) - ring_progress_bar.set_colors_from_map("viridis", "RGB") + # Add 2 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 2, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -221,7 +165,9 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): assert ring_config_colors == expected_colors # increase the number of bars to 6 - ring_progress_bar.set_number_of_bars(6) + for _ in range(4): + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 6, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -229,7 +175,9 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): assert ring_config_colors == expected_colors # decrease the number of bars to 3 - ring_progress_bar.set_number_of_bars(3) + for _ in range(3): + ring_progress_bar.remove_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 3, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -238,100 +186,284 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): def test_set_colors_directly(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() # setting as a list of rgb tuples colors = [(255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255)] - ring_progress_bar.set_colors_directly(colors) + ring_progress_bar.ring_progress_bar.set_colors_directly(colors) converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0] assert colors == converted_colors - ring_progress_bar.set_colors_directly((255, 0, 0, 255), 1) + ring_progress_bar.ring_progress_bar.set_colors_directly((255, 0, 0, 255), 1) converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0] assert converted_colors == [(255, 0, 0, 255), (255, 0, 0, 255), (0, 0, 255, 255)] def test_set_line_width(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() - ring_progress_bar.set_line_widths(5) + # Set line width for all rings + for ring in ring_progress_bar.rings: + ring.set_line_width(5) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [5, 5, 5] - ring_progress_bar.set_line_widths([10, 20, 30]) + # Set individual line widths + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_line_width([10, 20, 30][i]) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [10, 20, 30] - ring_progress_bar.set_line_widths(15, 1) + # Set line width for ring at index 1 + ring_progress_bar.rings[1].set_line_width(15) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [10, 15, 30] def test_set_gap(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + ring_progress_bar.add_ring() ring_progress_bar.set_gap(20) - assert ring_progress_bar.config.gap == 20 + assert ring_progress_bar.ring_progress_bar.gap == 20 == ring_progress_bar.gap -def test_auto_update(ring_progress_bar): - ring_progress_bar.enable_auto_updates(True) +def test_remove_ring_by_index(ring_progress_bar): + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() - scan_queue_status_scan_progress = { - "queue": { - "primary": { - "info": [{"active_request_block": {"report_instructions": [{"scan_progress": 10}]}}] - } - } - } - meta = {} - - ring_progress_bar.on_scan_queue_status(scan_queue_status_scan_progress, meta) - - assert ring_progress_bar._auto_updates is True - assert len(ring_progress_bar._rings) == 1 - assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections( - slot="on_scan_progress", endpoint=MessageEndpoints.scan_progress() - ) - - scan_queue_status_device_readback = { - "queue": { - "primary": { - "info": [ - { - "active_request_block": { - "report_instructions": [ - { - "readback": { - "devices": ["samx", "samy"], - "start": [1, 2], - "end": [10, 20], - } - } - ] - } - } - ] - } - } + assert ring_progress_bar.ring_progress_bar.num_bars == 5 + + # Store the ring at index 2 before removal + ring_at_3 = ring_progress_bar.rings[3] + + # Remove ring at index 2 (middle ring) + ring_progress_bar.remove_ring(index=2) + + assert ring_progress_bar.ring_progress_bar.num_bars == 4 + # Ring that was at index 3 is now at index 2 + assert ring_progress_bar.rings[2] == ring_at_3 + + +def test_remove_ring_updates_gaps(ring_progress_bar): + # Add 3 rings with default gap + for _ in range(3): + ring_progress_bar.add_ring() + + initial_gap = ring_progress_bar.gap + # Gaps should be: 0, gap, 2*gap + expected_gaps = [0, initial_gap, 2 * initial_gap] + actual_gaps = [ring.gap for ring in ring_progress_bar.rings] + assert actual_gaps == expected_gaps + + # Remove middle ring + ring_progress_bar.remove_ring(index=1) + + # Gaps should now be: 0, gap (for the remaining 2 rings) + expected_gaps = [0, initial_gap] + actual_gaps = [ring.gap for ring in ring_progress_bar.rings] + assert actual_gaps == expected_gaps + + +def test_center_label_property(ring_progress_bar): + test_text = "Test Label" + ring_progress_bar.center_label = test_text + + assert ring_progress_bar.center_label == test_text + assert ring_progress_bar.ring_progress_bar.center_label.text() == test_text + + +def test_color_map_property(ring_progress_bar): + # Add some rings + for _ in range(3): + ring_progress_bar.add_ring() + + # Set colormap via property + ring_progress_bar.color_map = "plasma" + + assert ring_progress_bar.color_map == "plasma" + assert ring_progress_bar.ring_progress_bar.color_map == "plasma" + + # Verify colors were applied + expected_colors = Colors.golden_angle_color("plasma", 3, "RGB") + actual_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert actual_colors == expected_colors + + +def test_color_map_property_invalid_colormap(ring_progress_bar): + # Make sure that invalid colormaps do not crash the application + ring_progress_bar.color_map = "plasma" + ring_progress_bar.color_map = "invalid_colormap_name" + + assert ring_progress_bar.color_map == "plasma" # Should remain unchanged + + +def test_ring_json_serialization(ring_progress_bar): + # Add rings with specific configurations + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + + # Configure rings + ring_progress_bar.rings[0].set_value(25) + ring_progress_bar.rings[0].set_color((255, 0, 0, 255)) + ring_progress_bar.rings[1].set_value(50) + ring_progress_bar.rings[1].set_line_width(15) + ring_progress_bar.rings[2].set_value(75) + ring_progress_bar.rings[2].set_precision(4) + + # Get JSON + json_str = ring_progress_bar.ring_json + + # Verify it's valid JSON + ring_configs = json.loads(json_str) + assert isinstance(ring_configs, list) + assert len(ring_configs) == 3 + + # Check some values + assert ring_configs[0]["value"] == 25 + assert ring_configs[1]["value"] == 50 + assert ring_configs[1]["line_width"] == 15 + assert ring_configs[2]["precision"] == 4 + + +def test_ring_json_deserialization(ring_progress_bar): + # Create JSON config + ring_configs = [ + {"value": 10, "color": (100, 150, 200, 255), "line_width": 8}, + {"value": 20, "precision": 2, "min_value": 0, "max_value": 50}, + {"value": 30, "direction": 1}, + ] + json_str = json.dumps(ring_configs) + + # Load via property + ring_progress_bar.ring_json = json_str + + # Verify rings were created + assert len(ring_progress_bar.rings) == 3 + + # Verify configurations + assert ring_progress_bar.rings[0].config.value == 10 + assert ring_progress_bar.rings[0].config.line_width == 8 + assert ring_progress_bar.rings[1].config.precision == 2 + assert ring_progress_bar.rings[1].config.max_value == 50 + assert ring_progress_bar.rings[2].config.direction == 1 + + +def test_ring_json_replaces_existing_rings(ring_progress_bar): + # Add some initial rings + for _ in range(5): + ring_progress_bar.add_ring() + + assert len(ring_progress_bar.rings) == 5 + + # Load new config with only 2 rings + ring_configs = [{"value": 10}, {"value": 20}] + ring_progress_bar.ring_json = json.dumps(ring_configs) + + # Should have replaced all rings + assert len(ring_progress_bar.rings) == 2 + assert ring_progress_bar.rings[0].config.value == 10 + assert ring_progress_bar.rings[1].config.value == 20 + + +def test_add_ring_with_config(ring_progress_bar): + config = { + "value": 42, + "color": (128, 64, 192, 255), + "line_width": 12, + "precision": 1, + "min_value": 0, + "max_value": 100, } - ring_progress_bar.on_scan_queue_status(scan_queue_status_device_readback, meta) - - assert ring_progress_bar._auto_updates is True - assert len(ring_progress_bar._rings) == 2 - assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections( - slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samx") - ) - assert ring_progress_bar._rings[1].config.connections == ProgressbarConnections( - slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samy") - ) - - assert ring_progress_bar._rings[0].config.min_value == 1 - assert ring_progress_bar._rings[0].config.max_value == 10 - assert ring_progress_bar._rings[1].config.min_value == 2 - assert ring_progress_bar._rings[1].config.max_value == 20 + ring_progress_bar.color_map = "" + ring_progress_bar.add_ring(config=config) + + assert len(ring_progress_bar.rings) == 1 + ring = ring_progress_bar.rings[0] + + assert ring.config.value == 42 + assert ring.config.line_width == 12 + assert ring.config.precision == 1 + assert ring.config.max_value == 100 + assert ring.config.color == "#8040c0" # Hex representation of (128, 64, 192) + + +def test_set_colors_directly_single_color_extends_to_all(ring_progress_bar): + # Add 4 rings + for _ in range(4): + ring_progress_bar.add_ring() + + # Set a single color, should extend to all rings + single_color = (200, 100, 50, 255) + ring_progress_bar.ring_progress_bar.set_colors_directly(single_color) + + colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert all(color == single_color for color in colors) + + +def test_set_colors_directly_list_too_short(ring_progress_bar): + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() + + # Provide only 2 colors + colors = [(255, 0, 0, 255), (0, 255, 0, 255)] + ring_progress_bar.ring_progress_bar.set_colors_directly(colors) + + # Last color should be extended to remaining rings + actual_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert actual_colors[0] == (255, 0, 0, 255) + assert actual_colors[1] == (0, 255, 0, 255) + assert all(color == (0, 255, 0, 255) for color in actual_colors[2:]) + + +def test_gap_affects_ring_positioning(ring_progress_bar): + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() + + initial_gap = ring_progress_bar.gap + + # Change gap + new_gap = 30 + ring_progress_bar.set_gap(new_gap) + + # Verify gaps are updated but update method is needed for visual changes + assert ring_progress_bar.gap == new_gap + + +def test_clear_all_rings(ring_progress_bar): + # Add multiple rings + for _ in range(5): + ring_progress_bar.add_ring() + + assert len(ring_progress_bar.rings) == 5 + + # Clear all + ring_progress_bar.ring_progress_bar.clear_all() + + assert len(ring_progress_bar.rings) == 0 + assert ring_progress_bar.ring_progress_bar.num_bars == 0 + + +def test_rings_property_returns_correct_list(ring_progress_bar): + # Add some rings + for _ in range(3): + ring_progress_bar.add_ring() + + rings_via_property = ring_progress_bar.rings + rings_direct = ring_progress_bar.ring_progress_bar.rings + + # Should return the same list + assert rings_via_property is rings_direct + assert len(rings_via_property) == 3 diff --git a/tests/unit_tests/test_ring_progress_bar_ring.py b/tests/unit_tests/test_ring_progress_bar_ring.py new file mode 100644 index 000000000..d3437b8d1 --- /dev/null +++ b/tests/unit_tests/test_ring_progress_bar_ring.py @@ -0,0 +1,602 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring + +from unittest.mock import MagicMock + +import pytest +from qtpy.QtGui import QColor + +from bec_widgets.tests.utils import FakeDevice +from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConfig, Ring +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressContainerWidget, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def ring_container(qtbot, mocked_client): + container = RingProgressContainerWidget() + qtbot.addWidget(container) + yield container + + +@pytest.fixture +def ring_widget(qtbot, ring_container, mocked_client): + ring = Ring(parent=ring_container, client=mocked_client) + qtbot.addWidget(ring) + qtbot.waitExposed(ring) + yield ring + + +@pytest.fixture +def ring_widget_with_device(ring_widget): + mock_device = FakeDevice(name="samx") + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + yield ring_widget + + +def test_ring_initialization(ring_widget): + assert ring_widget is not None + assert isinstance(ring_widget.config, ProgressbarConfig) + assert ring_widget.config.mode == "manual" + assert ring_widget.config.value == 0 + assert ring_widget.registered_slot is None + + +def test_ring_has_default_config_values(ring_widget): + assert ring_widget.config.direction == -1 + assert ring_widget.config.line_width == 20 + assert ring_widget.config.start_position == 90 + assert ring_widget.config.min_value == 0 + assert ring_widget.config.max_value == 100 + assert ring_widget.config.precision == 3 + + +################################### +# set_update method tests +################################### + + +def test_set_update_to_manual(ring_widget): + # Start in manual mode + assert ring_widget.config.mode == "manual" + + # Set to manual again (should return early) + ring_widget.set_update("manual") + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_to_scan(ring_widget): + # Mock the dispatcher to avoid actual connections + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + # Set to scan mode + ring_widget.set_update("scan") + + assert ring_widget.config.mode == "scan" + # Verify that connect_slot was called + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_scan_progress + assert "scan_progress" in str(call_args[0][1]) + + +def test_set_update_from_scan_to_manual(ring_widget): + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to scan mode first + ring_widget.set_update("scan") + assert ring_widget.config.mode == "scan" + + # Now switch back to manual + ring_widget.set_update("manual") + + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_to_device(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + # Set to device mode + test_device = "samx" + ring_widget.set_update("device", device=test_device) + + assert ring_widget.config.mode == "device" + assert ring_widget.config.device == test_device + assert ring_widget.config.signal == "samx" + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + + +def test_set_update_from_device_to_manual(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode first + ring_widget.set_update("device", device="samx") + assert ring_widget.config.mode == "device" + + # Switch to manual + ring_widget.set_update("manual") + + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_scan_to_device(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to scan mode first + ring_widget.set_update("scan") + assert ring_widget.config.mode == "scan" + + # Switch to device mode + ring_widget.set_update("device", device="samx") + + assert ring_widget.config.mode == "device" + assert ring_widget.config.device == "samx" + + +def test_set_update_device_to_scan(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode first + ring_widget.set_update("device", device="samx") + assert ring_widget.config.mode == "device" + + # Switch to scan mode + ring_widget.set_update("scan") + + assert ring_widget.config.mode == "scan" + + +def test_set_update_same_device_resubscribes(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode + test_device = "samx" + ring_widget.set_update("device", device=test_device) + + # Reset mocks + ring_widget.bec_dispatcher.connect_slot.reset_mock() + ring_widget.bec_dispatcher.disconnect_slot.reset_mock() + + # Set to same device mode again (should resubscribe) + ring_widget.set_update("device", device=test_device) + + # Should disconnect and reconnect + ring_widget.bec_dispatcher.disconnect_slot.assert_called_once() + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + + +def test_set_update_invalid_mode(ring_widget): + with pytest.raises(ValueError) as excinfo: + ring_widget.set_update("invalid_mode") + + assert "Unsupported mode: invalid_mode" in str(excinfo.value) + + +################################### +# Value and property tests +################################### + + +def test_set_value(ring_widget): + ring_widget.set_value(42.5) + assert ring_widget.config.value == 42.5 + + +def test_set_value_with_min_max_clamping(ring_widget): + ring_widget.set_min_max_values(0, 100) + + # Set value above max + ring_widget.set_value(150) + assert ring_widget.config.value == 100 + + # Set value below min + ring_widget.set_value(-10) + assert ring_widget.config.value == 0 + + +def test_set_precision(ring_widget): + ring_widget.set_precision(2) + assert ring_widget.config.precision == 2 + + ring_widget.set_value(10.12345) + assert ring_widget.config.value == 10.12 + + +def test_set_min_max_values(ring_widget): + ring_widget.set_min_max_values(10, 90) + + assert ring_widget.config.min_value == 10 + assert ring_widget.config.max_value == 90 + + +def test_set_line_width(ring_widget): + ring_widget.set_line_width(25) + assert ring_widget.config.line_width == 25 + + +def test_set_start_angle(ring_widget): + ring_widget.set_start_angle(180) + assert ring_widget.config.start_position == 180 + + +################################### +# Color management tests +################################### + + +def test_set_color(ring_widget): + test_color = (255, 128, 64, 255) + ring_widget.set_color(test_color) + + # Color is stored as hex string internally + assert ring_widget.color.getRgb() == test_color + + +def test_set_color_with_link_colors_updates_background(ring_widget): + # Enable color linking + ring_widget.config.link_colors = True + + # Store original background + original_bg = ring_widget.background_color.getRgb() + + test_color = (255, 100, 50, 255) + ring_widget.set_color(test_color) + + # Background should be derived using subtle_background_color + bg_color = ring_widget.background_color + # Background should have changed + assert bg_color.getRgb() != original_bg + # Background should be different from the main color + assert bg_color.getRgb() != test_color + + +def test_set_background_when_colors_unlinked(ring_widget): + # Disable color linking + ring_widget.config.link_colors = False + + test_bg = (100, 100, 100, 128) + ring_widget.set_background(test_bg) + + assert ring_widget.background_color.getRgb() == test_bg + + +def test_set_background_when_colors_linked_does_nothing(ring_widget): + # Enable color linking + ring_widget.config.link_colors = True + + original_bg = ring_widget.background_color.getRgb() + test_bg = (100, 100, 100, 128) + + ring_widget.set_background(test_bg) + + # Background should not change when colors are linked + assert ring_widget.background_color.getRgb() == original_bg + + +def test_color_link_derives_background(ring_widget): + ring_widget.config.link_colors = True + + bright_color = QColor(255, 255, 0, 255) # Bright yellow + original_bg = ring_widget.background_color.getRgb() + + ring_widget.set_color(bright_color.getRgb()) + + # Get the derived background color + bg_color = ring_widget.background_color + + # Background should have changed + assert bg_color.getRgb() != original_bg + # Background should be a subtle blend, not the same as the main color + assert bg_color.getRgb() != bright_color.getRgb() + + +def test_convert_color_from_tuple(ring_widget): + color_tuple = (200, 150, 100, 255) + qcolor = ring_widget.convert_color(color_tuple) + + assert isinstance(qcolor, QColor) + assert qcolor.getRgb() == color_tuple + + +def test_convert_color_from_hex_string(ring_widget): + hex_color = "#FF8040FF" + qcolor = ring_widget.convert_color(hex_color) + + assert isinstance(qcolor, QColor) + assert qcolor.isValid() + + +################################### +# Gap property tests +################################### + + +def test_gap_property(ring_widget): + ring_widget.gap = 15 + assert ring_widget.gap == 15 + + +################################### +# Config validation tests +################################### + + +def test_config_default_values(): + config = ProgressbarConfig() + + assert config.value == 0 + assert config.direction == -1 + assert config.line_width == 20 + assert config.start_position == 90 + assert config.min_value == 0 + assert config.max_value == 100 + assert config.precision == 3 + assert config.mode == "manual" + assert config.link_colors is True + + +def test_config_with_custom_values(): + config = ProgressbarConfig( + value=50, direction=1, line_width=20, min_value=10, max_value=90, precision=2, mode="scan" + ) + + assert config.value == 50 + assert config.direction == 1 + assert config.line_width == 20 + assert config.min_value == 10 + assert config.max_value == 90 + assert config.precision == 2 + assert config.mode == "scan" + + +################################### +# set_direction tests +################################### + + +def test_set_direction_clockwise(ring_widget): + ring_widget.set_direction(-1) + assert ring_widget.config.direction == -1 + + +def test_set_direction_counter_clockwise(ring_widget): + ring_widget.set_direction(1) + assert ring_widget.config.direction == 1 + + +################################### +# _update_device_connection tests +################################### + + +def test_update_device_connection_with_progress_signal(ring_widget_with_device): + ring_widget = ring_widget_with_device + samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx + samx._info["signals"]["progress"] = { + "obj_name": "samx_progress", + "component_name": "progress", + "signal_class": "ProgressSignal", + "kind_str": "hinted", + } + + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + ring_widget._update_device_connection("samx", "progress") + + # Should connect to device_progress endpoint + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_device_progress + + +def test_update_device_connection_with_hinted_signal(ring_widget): + mock_device = FakeDevice(name="samx") + mock_device._info = { + "signals": { + "samx": {"obj_name": "samx", "signal_class": "SomeOtherSignal", "kind_str": "hinted"} + } + } + + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + ring_widget._update_device_connection("samx", "samx") + + # Should connect to device_readback endpoint + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_device_readback + + +def test_update_device_connection_no_device_manager(ring_widget): + ring_widget.bec_dispatcher.client.device_manager = None + + with pytest.raises(ValueError) as excinfo: + ring_widget._update_device_connection("samx", "signal") + assert "Device manager is not available" in str(excinfo.value) + + +def test_update_device_connection_device_not_found(ring_widget): + mock_device = FakeDevice(name="samx") + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + + # Should return without raising an error + ring_widget._update_device_connection("nonexistent", "signal") + + +################################### +# on_scan_progress tests +################################### + + +def test_on_scan_progress_updates_value(ring_widget): + msg = {"value": 42, "max_value": 100} + meta = {"RID": "test_rid_123"} + + ring_widget.on_scan_progress(msg, meta) + + assert ring_widget.config.value == 42 + + +def test_on_scan_progress_updates_min_max_on_new_rid(ring_widget): + msg = {"value": 50, "max_value": 200} + meta = {"RID": "new_rid"} + + ring_widget.RID = "old_rid" + ring_widget.on_scan_progress(msg, meta) + + assert ring_widget.config.min_value == 0 + assert ring_widget.config.max_value == 200 + assert ring_widget.config.value == 50 + + +def test_on_scan_progress_same_rid_no_min_max_update(ring_widget): + msg = {"value": 75, "max_value": 300} + meta = {"RID": "same_rid"} + + ring_widget.RID = "same_rid" + ring_widget.set_min_max_values(0, 100) + + ring_widget.on_scan_progress(msg, meta) + + # Max value should not be updated when RID is the same + assert ring_widget.config.max_value == 100 + assert ring_widget.config.value == 75 + + +################################### +# on_device_readback tests +################################### + + +def test_on_device_readback_updates_value(ring_widget): + ring_widget.config.device = "samx" + ring_widget.config.signal = "readback" + + msg = {"signals": {"readback": {"value": 12.34}}} + meta = {} + + ring_widget.on_device_readback(msg, meta) + + assert ring_widget.config.value == 12.34 + + +def test_on_device_readback_uses_device_name_when_no_signal(ring_widget): + ring_widget.config.device = "samy" + ring_widget.config.signal = None + + msg = {"signals": {"samy": {"value": 56.78}}} + meta = {} + + ring_widget.on_device_readback(msg, meta) + + assert ring_widget.config.value == 56.78 + + +def test_on_device_readback_no_device_returns_early(ring_widget): + ring_widget.config.device = None + + msg = {"signals": {"samx": {"value": 99.99}}} + meta = {} + + initial_value = ring_widget.config.value + ring_widget.on_device_readback(msg, meta) + + # Value should not change + assert ring_widget.config.value == initial_value + + +def test_on_device_readback_missing_signal_data(ring_widget): + ring_widget.config.device = "samx" + ring_widget.config.signal = "missing_signal" + + msg = {"signals": {"other_signal": {"value": 11.11}}} + meta = {} + + initial_value = ring_widget.config.value + ring_widget.on_device_readback(msg, meta) + + # Value should not change when signal is missing + assert ring_widget.config.value == initial_value + + +################################### +# on_device_progress tests +################################### + + +def test_on_device_progress_updates_value_and_max(ring_widget): + ring_widget.config.device = "samx" + + msg = {"value": 30, "max_value": 150, "done": False} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + assert ring_widget.config.value == 30 + assert ring_widget.config.max_value == 150 + + +def test_on_device_progress_done_sets_to_max(ring_widget): + ring_widget.config.device = "samx" + + msg = {"value": 80, "max_value": 100, "done": True} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + # When done is True, value should be set to max_value + assert ring_widget.config.value == 100 + assert ring_widget.config.max_value == 100 + + +def test_on_device_progress_no_device_returns_early(ring_widget): + ring_widget.config.device = None + + msg = {"value": 50, "max_value": 100, "done": False} + meta = {} + + initial_value = ring_widget.config.value + initial_max = ring_widget.config.max_value + + ring_widget.on_device_progress(msg, meta) + + # Nothing should change + assert ring_widget.config.value == initial_value + assert ring_widget.config.max_value == initial_max + + +def test_on_device_progress_default_values(ring_widget): + ring_widget.config.device = "samx" + + # Message without value and max_value + msg = {} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + # Should use defaults: value=0, max_value=100 + assert ring_widget.config.value == 0 + assert ring_widget.config.max_value == 100 diff --git a/tests/unit_tests/test_ring_progress_settings.py b/tests/unit_tests/test_ring_progress_settings.py new file mode 100644 index 000000000..341e7ade2 --- /dev/null +++ b/tests/unit_tests/test_ring_progress_settings.py @@ -0,0 +1,66 @@ +import pytest + +from bec_widgets.utils.settings_dialog import SettingsDialog +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings +from tests.unit_tests.client_mocks import mocked_client + + +@pytest.fixture +def ring_progress_bar_widget(qtbot, mocked_client): + widget = RingProgressBar(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def rpb_settings_dialog(qtbot, ring_progress_bar_widget): + settings = RingSettings( + parent=ring_progress_bar_widget, target_widget=ring_progress_bar_widget, popup=True + ) + dialog = SettingsDialog( + ring_progress_bar_widget, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +@pytest.fixture +def rpb_settings_dialog_with_rings(qtbot, ring_progress_bar_widget): + ring_progress_bar_widget.add_ring() + ring_progress_bar_widget.add_ring() + settings = RingSettings( + parent=ring_progress_bar_widget, target_widget=ring_progress_bar_widget, popup=True + ) + dialog = SettingsDialog( + ring_progress_bar_widget, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +def test_ring_progress_settings_dialog_opens(rpb_settings_dialog): + """Test that the Ring Progress Bar settings dialog opens correctly.""" + dialog = rpb_settings_dialog + dialog.show() + assert dialog.isVisible() + assert dialog.windowTitle() == "Ring Progress Bar Settings" + dialog.accept() + + +def test_ring_progress_settings_dialog_with_rings(rpb_settings_dialog_with_rings): + """Test that the Ring Progress Bar settings dialog opens correctly with rings.""" + dialog = rpb_settings_dialog_with_rings + dialog.show() + assert dialog.isVisible() + assert dialog.windowTitle() == "Ring Progress Bar Settings" + dialog.accept() # Close the dialog diff --git a/tests/unit_tests/test_round_frame.py b/tests/unit_tests/test_round_frame.py index ba46219e6..18c7152e5 100644 --- a/tests/unit_tests/test_round_frame.py +++ b/tests/unit_tests/test_round_frame.py @@ -42,18 +42,6 @@ def test_set_radius(basic_rounded_frame): assert basic_rounded_frame.radius == 20 -def test_apply_theme_light(plot_rounded_frame): - plot_rounded_frame.apply_theme("light") - - assert plot_rounded_frame.background_color == "#e9ecef" - - -def test_apply_theme_dark(plot_rounded_frame): - plot_rounded_frame.apply_theme("dark") - - assert plot_rounded_frame.background_color == "#141414" - - def test_apply_plot_widget_style(plot_rounded_frame): # Verify that a PlotWidget can have its style applied plot_rounded_frame.apply_plot_widget_style(border="1px solid red") diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index fa9e0b55b..b4ecf906a 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -1,9 +1,28 @@ import argparse +from unittest.mock import patch import pytest from bec_lib.service_config import ServiceConfig +from qtpy.QtWidgets import QWidget from bec_widgets.cli.server import GUIServer +from bec_widgets.utils.bec_connector import BECConnector +from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat + +from .client_mocks import mocked_client + + +class DummyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.setObjectName("DummyWidget") + + +@pytest.fixture +def dummy_widget(qtbot, mocked_client): + widget = DummyWidget(client=mocked_client) + qtbot.addWidget(widget) + return widget @pytest.fixture @@ -14,6 +33,13 @@ def gui_server(): return GUIServer(args=args) +@pytest.fixture +def rpc_server(mocked_client): + rpc_server = RPCServer(gui_id="test_gui", client=mocked_client) + yield rpc_server + rpc_server.shutdown() + + def test_gui_server_start_server_without_service_config(gui_server): """ Test that the server is started with the correct arguments. @@ -30,3 +56,85 @@ def test_gui_server_get_service_config(gui_server): Test that the server is started with the correct arguments. """ assert gui_server._get_service_config().config == ServiceConfig().config + + +def test_singleshot_rpc_repeat_raises_on_repeated_singleshot(rpc_server): + """ + Test that a singleshot RPC method raises an error when called multiple times. + """ + repeat = SingleshotRPCRepeat() + rpc_server._rpc_singleshot_repeats["test_method"] = repeat + + repeat += 100 # First call should work fine + with pytest.raises(RegistryNotReadyError): + repeat += 2000 # Should raise here + + +def test_serialize_result_and_send_with_singleshot_retry(rpc_server, qtbot, dummy_widget): + """ + Test that serialize_result_and_send retries when RegistryNotReadyError is raised, + and eventually succeeds when the object becomes registered. + """ + request_id = "test_request_123" + + dummy = dummy_widget + + # Track how many times serialize_object is called + call_count = 0 + + def serialize_side_effect(obj): + nonlocal call_count + call_count += 1 + # First 2 calls raise RegistryNotReadyError + if call_count <= 2: + raise RegistryNotReadyError(f"Not ready yet (call {call_count})") + # Third call succeeds + return {"gui_id": dummy.gui_id, "success": True} + + # Patch serialize_object to control when it raises RegistryNotReadyError + with patch.object(rpc_server, "serialize_object", side_effect=serialize_side_effect): + with patch.object(rpc_server, "send_response") as mock_send_response: + # Start the serialization process + rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + rpc_server.serialize_result_and_send(request_id, dummy) + + # Verify that serialize_object was called 3 times + qtbot.waitUntil(lambda: call_count >= 3, timeout=5000) + + # Verify that send_response was called with success + mock_send_response.assert_called_once() + args = mock_send_response.call_args[0] + assert args[0] == request_id + assert args[1] is True # accepted=True + assert "result" in args[2] + + +def test_serialize_result_and_send_max_delay_exceeded(rpc_server, qtbot, dummy_widget): + """ + Test that serialize_result_and_send sends an error response when max delay is exceeded. + """ + request_id = "test_request_456" + + dummy = dummy_widget + + # Always raise RegistryNotReadyError + with patch.object( + rpc_server, "serialize_object", side_effect=RegistryNotReadyError("Always not ready") + ): + with patch.object(rpc_server, "send_response") as mock_send_response: + # Start the serialization process + rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + rpc_server.serialize_result_and_send(request_id, dummy) + + # Process event loop to allow all singleshot timers to fire + # Max delay is 2000ms, with 100ms retry intervals = ~20 retries + # Wait for the max delay plus some buffer + qtbot.wait(2500) + + # Verify that send_response was called with an error + mock_send_response.assert_called() + args = mock_send_response.call_args[0] + assert args[0] == request_id + assert args[1] is False # accepted=False + assert "error" in args[2] + assert "Max delay exceeded" in args[2]["error"] diff --git a/tests/unit_tests/test_rpc_widget_handler.py b/tests/unit_tests/test_rpc_widget_handler.py index 1f2fc7683..ed213b8bb 100644 --- a/tests/unit_tests/test_rpc_widget_handler.py +++ b/tests/unit_tests/test_rpc_widget_handler.py @@ -1,20 +1,15 @@ -import enum -from importlib import reload -from types import SimpleNamespace -from unittest.mock import MagicMock, call, patch +from unittest.mock import patch -from bec_widgets.cli import client -from bec_widgets.cli.rpc.rpc_base import RPCBase from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo -from bec_widgets.widgets.containers.dock.dock import BECDock def test_rpc_widget_handler(): handler = RPCWidgetHandler() assert "Image" in handler.widget_classes assert "RingProgressBar" in handler.widget_classes + assert "BECDockArea" in handler.widget_classes class _TestPluginWidget(BECWidget): ... diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 30ce317d5..75f48af15 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -505,7 +505,13 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client): args, kwargs = scan_control.get_scan_parameters(bec_object=False) assert args == ["samx", 0.0, 2.0] - assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1} + assert kwargs == { + "steps": 10, + "relative": False, + "exp_time": 2.0, + "burst_at_each_point": 1, + "metadata": {"sample_name": ""}, + } TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"} @@ -557,7 +563,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl): scans = SimpleNamespace(grid_scan=MagicMock()) with ( patch.object(scan_control, "scans", scans), - patch.object(scan_control, "get_scan_parameters", lambda: ((), {})), + patch.object(scan_control, "get_scan_parameters", lambda: ((), {"metadata": TEST_MD})), ): scan_control.run_scan() scans.grid_scan.assert_called_once_with(metadata=TEST_MD) diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 8476ebe5f..f0ba5620a 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -1,4 +1,4 @@ -import json +from unittest.mock import patch import numpy as np @@ -7,6 +7,9 @@ ScatterDeviceSignal, ) from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform +from bec_widgets.widgets.plots.scatter_waveform.settings.scatter_curve_setting import ( + ScatterCurveSettings, +) from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client from .conftest import create_widget @@ -46,28 +49,6 @@ def test_scatter_waveform_color_map(qtbot, mocked_client): assert swf.color_map == "plasma" -def test_scatter_waveform_curve_json(qtbot, mocked_client): - swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) - - # Add a device-based scatter curve - swf.plot(x_name="samx", y_name="samy", z_name="bpm4i", label="test_curve") - - json_str = swf.curve_json - data = json.loads(json_str) - assert isinstance(data, dict) - assert data["label"] == "test_curve" - assert data["x_device"]["name"] == "samx" - assert data["y_device"]["name"] == "samy" - assert data["z_device"]["name"] == "bpm4i" - - # Clear and reload from JSON - swf.clear_all() - assert swf.main_curve.getData() == (None, None) - - swf.curve_json = json_str - assert swf.main_curve.config.label == "test_curve" - - def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeypatch): swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) @@ -151,3 +132,413 @@ def test_scatter_waveform_scan_progress(qtbot, mocked_client, monkeypatch): # swf.scatter_dialog.close() # assert swf.scatter_dialog is None # assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog" + + +################################################################################ +# Device Property Tests +################################################################################ + + +def test_device_safe_properties_get(qtbot, mocked_client): + """Test that device SafeProperty getters work correctly.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Initially devices should be empty + assert swf.x_device_name == "" + assert swf.x_device_entry == "" + assert swf.y_device_name == "" + assert swf.y_device_entry == "" + assert swf.z_device_name == "" + assert swf.z_device_entry == "" + + # Set devices via plot + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Check properties return device names and entries separately + assert swf.x_device_name == "samx" + assert swf.x_device_entry # Should have some entry + assert swf.y_device_name == "samy" + assert swf.y_device_entry # Should have some entry + assert swf.z_device_name == "bpm4i" + assert swf.z_device_entry # Should have some entry + + +def test_device_safe_properties_set_name(qtbot, mocked_client): + """Test that device SafeProperty setters work for device names.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set x_device_name - should auto-validate entry + swf.x_device_name = "samx" + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.x_device.name == "samx" + assert swf._main_curve.config.x_device.entry is not None # Entry should be validated + assert swf.x_device_name == "samx" + + # Set y_device_name + swf.y_device_name = "samy" + assert swf._main_curve.config.y_device is not None + assert swf._main_curve.config.y_device.name == "samy" + assert swf._main_curve.config.y_device.entry is not None + assert swf.y_device_name == "samy" + + # Set z_device_name + swf.z_device_name = "bpm4i" + assert swf._main_curve.config.z_device is not None + assert swf._main_curve.config.z_device.name == "bpm4i" + assert swf._main_curve.config.z_device.entry is not None + assert swf.z_device_name == "bpm4i" + + +def test_device_safe_properties_set_entry(qtbot, mocked_client): + """Test that device entry properties can override default entries.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device name first - this auto-validates entry + swf.x_device_name = "samx" + initial_entry = swf.x_device_entry + assert initial_entry # Should have auto-validated entry + + # Override with specific entry + swf.x_device_entry = "samx" + assert swf._main_curve.config.x_device.entry == "samx" + assert swf.x_device_entry == "samx" + + # Same for y device + swf.y_device_name = "samy" + swf.y_device_entry = "samy_setpoint" + assert swf._main_curve.config.y_device.entry == "samy_setpoint" + + # Same for z device + swf.z_device_name = "bpm4i" + swf.z_device_entry = "bpm4i" + assert swf._main_curve.config.z_device.entry == "bpm4i" + + +def test_device_entry_cannot_be_set_without_name(qtbot, mocked_client): + """Test that setting entry without device name logs warning and does nothing.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Try to set entry without device name + swf.x_device_entry = "some_entry" + # Should not crash, entry should remain empty + assert swf.x_device_entry == "" + assert swf._main_curve.config.x_device is None + + +def test_device_safe_properties_set_empty(qtbot, mocked_client): + """Test that device SafeProperty setters handle empty strings.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device first + swf.x_device_name = "samx" + assert swf._main_curve.config.x_device is not None + + # Set to empty string - should clear the device + swf.x_device_name = "" + assert swf.x_device_name == "" + assert swf._main_curve.config.x_device is None + + +def test_device_safe_properties_auto_plot(qtbot, mocked_client): + """Test that setting all three devices triggers auto-plot.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set all three devices + swf.x_device_name = "samx" + swf.y_device_name = "samy" + swf.z_device_name = "bpm4i" + + # Check that plot was called (config should be updated) + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.y_device is not None + assert swf._main_curve.config.z_device is not None + + +def test_device_properties_update_labels(qtbot, mocked_client): + """Test that setting device properties updates axis labels.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set x device - should update x label + swf.x_device_name = "samx" + assert swf.x_label == "samx" + + # Set y device - should update y label + swf.y_device_name = "samy" + assert swf.y_label == "samy" + + # Note: ScatterWaveform doesn't have a title like Heatmap does for z_device + + +def test_device_properties_partial_configuration(qtbot, mocked_client): + """Test that widget handles partial device configuration gracefully.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set only x device + swf.x_device_name = "samx" + assert swf.x_device_name == "samx" + assert swf.y_device_name == "" + assert swf.z_device_name == "" + + # Set only y device (x already set) + swf.y_device_name = "samy" + assert swf.x_device_name == "samx" + assert swf.y_device_name == "samy" + assert swf.z_device_name == "" + + # Auto-plot should not trigger yet (z missing) + # But devices should be configured + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.y_device is not None + + +def test_device_properties_in_user_access(qtbot, mocked_client): + """Test that device properties are exposed in USER_ACCESS for RPC.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + assert "x_device_name" in ScatterWaveform.USER_ACCESS + assert "x_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "x_device_entry" in ScatterWaveform.USER_ACCESS + assert "x_device_entry.setter" in ScatterWaveform.USER_ACCESS + assert "y_device_name" in ScatterWaveform.USER_ACCESS + assert "y_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "y_device_entry" in ScatterWaveform.USER_ACCESS + assert "y_device_entry.setter" in ScatterWaveform.USER_ACCESS + assert "z_device_name" in ScatterWaveform.USER_ACCESS + assert "z_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "z_device_entry" in ScatterWaveform.USER_ACCESS + assert "z_device_entry.setter" in ScatterWaveform.USER_ACCESS + + +def test_device_properties_validation(qtbot, mocked_client): + """Test that device entries are validated through entry_validator.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device name - entry should be auto-validated + swf.x_device_name = "samx" + initial_entry = swf.x_device_entry + + # The entry should be validated (will be "samx" in the mock) + assert initial_entry == "samx" + + # Set a different entry - should also be validated + swf.x_device_entry = "samx" # Use same name as validated entry + assert swf.x_device_entry == "samx" + + +def test_device_properties_with_plot_method(qtbot, mocked_client): + """Test that device properties reflect values set via plot() method.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Use plot method + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Properties should reflect the plotted devices + assert swf.x_device_name == "samx" + assert swf.y_device_name == "samy" + assert swf.z_device_name == "bpm4i" + + # Entries should be validated + assert swf.x_device_entry == "samx" + assert swf.y_device_entry == "samy" + assert swf.z_device_entry == "bpm4i" + + +def test_device_properties_overwrite_via_properties(qtbot, mocked_client): + """Test that device properties can overwrite values set via plot().""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # First set via plot + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Overwrite x device via properties + swf.x_device_name = "samz" + assert swf.x_device_name == "samz" + assert swf._main_curve.config.x_device.name == "samz" + + # Overwrite y device entry + swf.y_device_entry = "samy" + assert swf.y_device_entry == "samy" + + +def test_device_properties_clearing_devices(qtbot, mocked_client): + """Test clearing devices by setting to empty string.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set all devices + swf.x_device_name = "samx" + swf.y_device_name = "samy" + swf.z_device_name = "bpm4i" + + # Clear x device + swf.x_device_name = "" + assert swf.x_device_name == "" + assert swf._main_curve.config.x_device is None + + # Y and Z should still be set + assert swf.y_device_name == "samy" + assert swf.z_device_name == "bpm4i" + + +def test_device_properties_property_changed_signal(qtbot, mocked_client): + """Test that property_changed signal is emitted when devices are set.""" + from unittest.mock import Mock + + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Connect mock to property_changed signal + mock_handler = Mock() + swf.property_changed.connect(mock_handler) + + # Set device name + swf.x_device_name = "samx" + + # Signal should have been emitted + assert mock_handler.called + # Check it was called with correct arguments + mock_handler.assert_any_call("x_device_name", "samx") + + +def test_device_entry_validation_with_invalid_device(qtbot, mocked_client): + """Test that invalid device names are handled gracefully.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Try to set invalid device name + swf.x_device_name = "nonexistent_device" + + # Should not crash, but device might not be set if validation fails + # The implementation silently fails, so we just check it doesn't crash + + +def test_device_properties_sequential_entry_changes(qtbot, mocked_client): + """Test changing device entry multiple times.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device + swf.x_device_name = "samx" + + # Change entry multiple times + swf.x_device_entry = "samx_velocity" + assert swf.x_device_entry == "samx_velocity" + + swf.x_device_entry = "samx_setpoint" + assert swf.x_device_entry == "samx_setpoint" + + swf.x_device_entry = "samx" + assert swf.x_device_entry == "samx" + + +def test_device_properties_with_none_values(qtbot, mocked_client): + """Test that None values are handled as empty strings.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Device name None should be treated as empty + swf.x_device_name = None + assert swf.x_device_name == "" + + # Set a device first + swf.y_device_name = "samy" + + # Entry None should not change anything + swf.y_device_entry = None + assert swf.y_device_entry # Should still have validated entry + + +################################################################################ +# ScatterCurveSettings Tests +################################################################################ + + +def test_scatter_curve_settings_accept_changes(qtbot, mocked_client): + """Test that accept_changes correctly extracts data from widgets and calls plot().""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set up the widgets with test values + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + + # Mock the plot method to verify it gets called with correct arguments + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + + # Verify plot was called + mock_plot.assert_called_once() + + # Get the call arguments + call_kwargs = mock_plot.call_args[1] + + # Verify device names were extracted correctly + assert call_kwargs["x_name"] == "samx" + assert call_kwargs["y_name"] == "samy" + assert call_kwargs["z_name"] == "bpm4i" + + +def test_scatter_curve_settings_accept_changes_with_entries(qtbot, mocked_client): + """Test that accept_changes correctly extracts signal entries from SignalComboBox.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set devices first to populate signal comboboxes + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + qtbot.wait(100) # Allow time for signals to populate + + # Mock the plot method + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + + mock_plot.assert_called_once() + call_kwargs = mock_plot.call_args[1] + + # Verify entries are extracted (will use get_signal_name()) + assert "x_entry" in call_kwargs + assert "y_entry" in call_kwargs + assert "z_entry" in call_kwargs + + +def test_scatter_curve_settings_accept_changes_color_map(qtbot, mocked_client): + """Test that accept_changes correctly extracts color_map from widget.""" + + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set devices + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + + # Get the current colormap + color_map = settings.ui.color_map.colormap + + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + call_kwargs = mock_plot.call_args[1] + assert call_kwargs["color_map"] == color_map + + +def test_scatter_curve_settings_fetch_all_properties(qtbot, mocked_client): + """Test that fetch_all_properties correctly populates the settings from target widget.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # First set up the scatter waveform with some data + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Create the settings widget - it should fetch properties automatically + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Verify the settings widget has fetched the values + assert settings.ui.x_name.currentText() == "samx" + assert settings.ui.y_name.currentText() == "samy" + assert settings.ui.z_name.currentText() == "bpm4i" diff --git a/tests/unit_tests/test_screen_utils.py b/tests/unit_tests/test_screen_utils.py new file mode 100644 index 000000000..32d469b3b --- /dev/null +++ b/tests/unit_tests/test_screen_utils.py @@ -0,0 +1,38 @@ +from qtpy.QtCore import QRect +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.screen_utils import ( + apply_centered_size, + centered_geometry, + main_app_size_for_screen, +) + + +def test_centered_geometry_returns_expected_tuple(): + available = QRect(100, 50, 800, 600) + result = centered_geometry(available, 400, 300) + assert result == (300, 200, 400, 300) + + +def test_main_app_size_for_screen_respects_16_9_and_screen_caps(): + available = QRect(0, 0, 1920, 1080) + width, height = main_app_size_for_screen(available) + assert (width, height) == (1728, 972) + + narrow = QRect(0, 0, 1000, 800) + width, height = main_app_size_for_screen(narrow) + assert (width, height) == (900, 506) + + +def test_apply_centered_size_uses_provided_geometry(qtbot): + widget = QWidget() + qtbot.addWidget(widget) + + available = QRect(10, 20, 600, 400) + apply_centered_size(widget, 200, 100, available=available) + + geometry = widget.geometry() + assert geometry.x() == 210 + assert geometry.y() == 170 + assert geometry.width() == 200 + assert geometry.height() == 100 diff --git a/tests/unit_tests/test_script_tree_widget.py b/tests/unit_tests/test_script_tree_widget.py index e69ccae7c..964219769 100644 --- a/tests/unit_tests/test_script_tree_widget.py +++ b/tests/unit_tests/test_script_tree_widget.py @@ -55,20 +55,16 @@ def test_script_tree_hover_events(script_tree, qtbot): # Send the event to the viewport (the event filter is installed on the viewport) script_tree.eventFilter(viewport, mouse_event) - qtbot.wait(100) # Allow time for the hover to be processed - # Now, the hover index should be set to the first item - assert script_tree.delegate.hovered_index.isValid() == True + qtbot.waitUntil(lambda: script_tree.delegate.hovered_index.isValid(), timeout=5000) assert script_tree.delegate.hovered_index.row() == index.row() # Simulate mouse leaving the viewport leave_event = QEvent(QEvent.Type.Leave) script_tree.eventFilter(viewport, leave_event) - qtbot.wait(100) # Allow time for the leave event to be processed - # After leaving, no item should be hovered - assert script_tree.delegate.hovered_index.isValid() == False + qtbot.waitUntil(lambda: not script_tree.delegate.hovered_index.isValid(), timeout=5000) @pytest.mark.timeout(10) diff --git a/tests/unit_tests/test_stop_button.py b/tests/unit_tests/test_stop_button.py index b5ecdc1f9..e428a7dec 100644 --- a/tests/unit_tests/test_stop_button.py +++ b/tests/unit_tests/test_stop_button.py @@ -17,10 +17,6 @@ def stop_button(qtbot, mocked_client): def test_stop_button(stop_button): assert stop_button.button.text() == "Stop" - assert ( - stop_button.button.styleSheet() - == "background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;" - ) stop_button.button.click() assert stop_button.queue.request_scan_halt.called stop_button.close() diff --git a/tests/unit_tests/test_utils_bec_list.py b/tests/unit_tests/test_utils_bec_list.py new file mode 100644 index 000000000..17fcd30f0 --- /dev/null +++ b/tests/unit_tests/test_utils_bec_list.py @@ -0,0 +1,128 @@ +"""Tests for the BECList widget.""" + +from unittest.mock import MagicMock + +import pytest +from qtpy import QtWidgets + +from bec_widgets.utils.bec_list import BECList + + +@pytest.fixture +def bec_list(qtbot): + widget = BECList() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def sample_widget(qtbot): + widget = QtWidgets.QLabel("sample") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + +class TestBECList: + def test_add_widget_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key1", sample_widget) + + assert "key1" in bec_list + assert bec_list.count() == 1 + retrieved_widget = bec_list.get_widget("key1") + assert retrieved_widget == sample_widget + retrieved_item = bec_list.get_item("key1") + assert retrieved_item is not None + assert bec_list.itemWidget(retrieved_item) == sample_widget + + def test_add_widget_item_replaces_existing(self, bec_list, sample_widget, qtbot): + bec_list.add_widget_item("key", sample_widget) + replacement = QtWidgets.QLabel("replacement") + qtbot.addWidget(replacement) + qtbot.waitExposed(replacement) + + bec_list.add_widget_item("key", replacement) + + assert bec_list.count() == 1 + assert bec_list.get_widget("key") == replacement + # ensure first widget no longer tracked + assert sample_widget not in bec_list.get_widgets() + + def test_remove_widget_item(self, bec_list, sample_widget, monkeypatch): + bec_list.add_widget_item("key", sample_widget) + + close_mock = MagicMock() + delete_mock = MagicMock() + monkeypatch.setattr(sample_widget, "close", close_mock) + monkeypatch.setattr(sample_widget, "deleteLater", delete_mock) + + bec_list.remove_widget_item("key") + + assert bec_list.count() == 0 + assert "key" not in bec_list + close_mock.assert_called_once() + delete_mock.assert_called_once() + + def test_remove_widget_item_missing_key(self, bec_list): + bec_list.remove_widget_item("missing") + assert bec_list.count() == 0 + + def test_clear_widgets(self, bec_list, qtbot): + for key in ["a", "b", "c"]: + label = QtWidgets.QLabel(key) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + bec_list.clear_widgets() + + assert bec_list.count() == 0 + assert bec_list.get_widgets() == [] + assert bec_list.get_all_keys() == [] + + def test_get_widget_and_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item("key") + assert item is not None + assert bec_list.get_widget_for_item(item) == sample_widget + assert bec_list.get_widget("key") == sample_widget + + def test_get_item_for_widget(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item_for_widget(sample_widget) + assert item is not None + assert bec_list.itemWidget(item) == sample_widget + + def test_get_all_keys(self, bec_list, qtbot): + labels = [] + for key in ["k1", "k2", "k3"]: + label = QtWidgets.QLabel(key) + labels.append(label) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + assert sorted(bec_list.get_all_keys()) == ["k1", "k2", "k3"] + assert set(bec_list.get_widgets()) == set(labels) + + def test_get_widget_for_item_unknown(self, bec_list, sample_widget): + unrelated_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(unrelated_item) is None + + bec_list.add_widget_item("key", sample_widget) + other_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(other_item) is None + + def test_get_item_for_widget_unknown(self, bec_list, qtbot): + label = QtWidgets.QLabel("orphan") + qtbot.addWidget(label) + qtbot.waitExposed(label) + assert bec_list.get_item_for_widget(label) is None + + def test_contains(self, bec_list, sample_widget): + assert "key" not in bec_list + bec_list.add_widget_item("key", sample_widget) + assert "key" in bec_list diff --git a/tests/unit_tests/test_utils_bec_login.py b/tests/unit_tests/test_utils_bec_login.py new file mode 100644 index 000000000..44fb59dea --- /dev/null +++ b/tests/unit_tests/test_utils_bec_login.py @@ -0,0 +1,52 @@ +"""Test the BEC Login widget""" + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLineEdit + +from bec_widgets.utils.bec_login import BECLogin + + +@pytest.fixture +def login_dialog(qtbot): + """Fixture to create a BECLogin instance.""" + dialog = BECLogin() + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) # Ensure the dialog is fully shown before running tests + return dialog + + +def test_utils_login_dialog_initialization(login_dialog, qtbot): + """Test that the BECLogin initializes correctly.""" + assert login_dialog.windowTitle() == "Login" + assert login_dialog.username.placeholderText() == "Username" + assert login_dialog.password.placeholderText() == "Password" + assert login_dialog.password.echoMode() == QLineEdit.EchoMode.Password + assert login_dialog.ok_btn.text() == "Sign in" + + # Initially, this should be empty + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton) + assert blocker.args == ["", ""] + + +def test_utils_login_dialog_emit_credentials(login_dialog, qtbot): + """Test that the BECLogin emits credentials correctly.""" + test_username = "testuser " + test_password = "testpass" + + login_dialog.username.setText(test_username) + login_dialog.password.setText(test_password) + + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton) + + assert blocker.args == [test_username.strip(), test_password] + assert login_dialog.password.text() == "" # Password should be cleared after emitting + + login_dialog.password.setText(test_password) + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.keyClick(login_dialog.password, Qt.Key.Key_Return) + + assert blocker.args == [test_username.strip(), test_password] + assert login_dialog.password.text() == "" # Password should be cleared after emitting diff --git a/tests/unit_tests/test_vscode_widget.py b/tests/unit_tests/test_vscode_widget.py deleted file mode 100644 index d4210cba1..000000000 --- a/tests/unit_tests/test_vscode_widget.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -import shlex -import subprocess -from unittest import mock - -import pytest - -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor - -from .client_mocks import mocked_client - - -@pytest.fixture -def vscode_widget(qtbot, mocked_client): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.subprocess.Popen") as mock_popen: - widget = VSCodeEditor(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_vscode_widget(qtbot, vscode_widget): - assert vscode_widget.process is not None - assert vscode_widget._url == f"http://127.0.0.1:{vscode_widget.port}?tkn=bec" - - -def test_start_server(qtbot, mocked_client): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.killpg") as mock_killpg: - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.getpgid") as mock_getpgid: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.subprocess.Popen" - ) as mock_popen: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.select.select" - ) as mock_select: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.get_free_port" - ) as mock_get_free_port: - mock_get_free_port.return_value = 12345 - mock_process = mock.Mock() - mock_process.stdout.fileno.return_value = 1 - mock_process.poll.return_value = None - mock_process.stdout.read.return_value = f"available at http://{VSCodeEditor.host}:{12345}?tkn={VSCodeEditor.token}" - mock_popen.return_value = mock_process - mock_select.return_value = [[mock_process.stdout], [], []] - - widget = VSCodeEditor(client=mocked_client) - widget.close() - widget.deleteLater() - - assert ( - mock.call( - shlex.split( - f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms" - ), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - preexec_fn=os.setsid, - env=mock.ANY, - ) - in mock_popen.mock_calls - ) - - -@pytest.fixture -def patched_vscode_process(qtbot, vscode_widget): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.killpg") as mock_killpg: - mock_killpg.reset_mock() - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.getpgid") as mock_getpgid: - mock_getpgid.return_value = 123 - vscode_widget.process = mock.Mock() - yield vscode_widget, mock_killpg - - -def test_vscode_cleanup(qtbot, patched_vscode_process): - vscode_patched, mock_killpg = patched_vscode_process - vscode_patched.process.pid = 123 - vscode_patched.process.poll.return_value = None - vscode_patched.cleanup_vscode() - mock_killpg.assert_called_once_with(123, 15) - vscode_patched.process.wait.assert_called_once() - - -def test_close_event_on_terminated_code(qtbot, patched_vscode_process): - vscode_patched, mock_killpg = patched_vscode_process - vscode_patched.process.pid = 123 - vscode_patched.process.poll.return_value = 0 - vscode_patched.cleanup_vscode() - mock_killpg.assert_not_called() - vscode_patched.process.wait.assert_not_called() diff --git a/tests/unit_tests/test_web_console.py b/tests/unit_tests/test_web_console.py index 3da2f9a0e..be49cff11 100644 --- a/tests/unit_tests/test_web_console.py +++ b/tests/unit_tests/test_web_console.py @@ -1,25 +1,69 @@ from unittest import mock import pytest +from qtpy.QtCore import Qt +from qtpy.QtGui import QHideEvent from qtpy.QtNetwork import QAuthenticator -from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry +from bec_widgets.widgets.editors.web_console.web_console import ( + BECShell, + ConsoleMode, + WebConsole, + _web_console_registry, +) from .client_mocks import mocked_client @pytest.fixture -def console_widget(qtbot, mocked_client): +def mocked_server_startup(): + """Mock the web console server startup process.""" with mock.patch( "bec_widgets.widgets.editors.web_console.web_console.subprocess" ) as mock_subprocess: with mock.patch.object(_web_console_registry, "_wait_for_server_port"): _web_console_registry._server_port = 12345 - # Create the WebConsole widget - widget = WebConsole(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget + yield mock_subprocess + + +def static_console(qtbot, client, unique_id: str | None = None): + """Fixture to provide a static unique_id for WebConsole tests.""" + if unique_id is None: + widget = WebConsole(client=client) + else: + widget = WebConsole(client=client, unique_id=unique_id) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + +@pytest.fixture +def console_widget(qtbot, mocked_client, mocked_server_startup): + """Create a WebConsole widget with mocked server startup.""" + yield static_console(qtbot, mocked_client) + + +@pytest.fixture +def bec_shell_widget(qtbot, mocked_client, mocked_server_startup): + """Create a BECShell widget with mocked server startup.""" + widget = BECShell(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup): + """Create a WebConsole widget with a static unique ID.""" + yield static_console(qtbot, mocked_client, unique_id="test_console") + + +@pytest.fixture +def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup): + """Create two WebConsole widgets sharing the same unique ID.""" + widget1 = static_console(qtbot, mocked_client, unique_id="shared_console") + widget2 = static_console(qtbot, mocked_client, unique_id="shared_console") + yield widget1, widget2 def test_web_console_widget_initialization(console_widget): @@ -34,7 +78,7 @@ def test_web_console_write(console_widget): with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!") - assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls + assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls def test_web_console_write_no_return(console_widget): @@ -42,7 +86,7 @@ def test_web_console_write_no_return(console_widget): with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!", send_return=False) - assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls + assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls assert mock_run_js.call_count == 1 @@ -138,6 +182,20 @@ def mock_run_js(script, callback=None): assert not console_widget._startup_timer.isActive() +def test_bec_shell_startup_contains_gui_id(bec_shell_widget): + """Test that the BEC shell startup command includes the GUI ID.""" + bec_shell = bec_shell_widget + + assert bec_shell._is_bec_shell + assert bec_shell._unique_id == "bec_shell" + + assert bec_shell.startup_cmd == "bec --nogui" + + with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server: + mock_cli_server.gui_id = "test_gui_id" + assert bec_shell.startup_cmd == "bec --gui-id test_gui_id" + + def test_web_console_set_readonly(console_widget): # Test the set_readonly method console_widget.set_readonly(True) @@ -145,3 +203,274 @@ def test_web_console_set_readonly(console_widget): console_widget.set_readonly(False) assert console_widget.isEnabled() + + +def test_web_console_with_unique_id(console_widget_with_static_id): + """Test creating a WebConsole with a unique_id.""" + widget = console_widget_with_static_id + + assert widget._unique_id == "test_console" + assert widget._unique_id in _web_console_registry._page_registry + page_info = _web_console_registry.get_page_info("test_console") + assert page_info is not None + assert page_info.owner_gui_id == widget.gui_id + assert widget.gui_id in page_info.widget_ids + + +def test_web_console_page_sharing(two_console_widgets_same_id): + """Test that two widgets can share the same page using unique_id.""" + widget1, widget2 = two_console_widgets_same_id + + # Both should reference the same page in the registry + page_info = _web_console_registry.get_page_info("shared_console") + assert page_info is not None + assert widget1.gui_id in page_info.widget_ids + assert widget2.gui_id in page_info.widget_ids + assert widget1.page == widget2.page + + +def test_web_console_has_ownership(console_widget_with_static_id): + """Test the has_ownership method.""" + widget = console_widget_with_static_id + + # Widget should have ownership by default + assert widget.has_ownership() + + +def test_web_console_yield_ownership(console_widget_with_static_id): + """Test yielding ownership of a page.""" + widget = console_widget_with_static_id + + assert widget.has_ownership() + + # Yield ownership + widget.yield_ownership() + + # Widget should no longer have ownership + assert not widget.has_ownership() + page_info = _web_console_registry.get_page_info("test_console") + assert page_info.owner_gui_id is None + # Overlay should be shown + assert widget._mode == ConsoleMode.INACTIVE + + +def test_web_console_take_page_ownership(two_console_widgets_same_id): + """Test taking ownership of a page.""" + widget1, widget2 = two_console_widgets_same_id + + # Widget1 should have ownership initially + assert widget1.has_ownership() + assert not widget2.has_ownership() + + # Widget2 takes ownership + widget2.take_page_ownership() + + # Now widget2 should have ownership + assert not widget1.has_ownership() + assert widget2.has_ownership() + + assert widget2._mode == ConsoleMode.ACTIVE + assert widget1._mode == ConsoleMode.INACTIVE + + +def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id): + """Test that hideEvent yields ownership.""" + widget = console_widget_with_static_id + + assert widget.has_ownership() + + # Hide the widget. Note that we cannot call widget.hide() directly + # because it doesn't trigger the hideEvent in tests as widgets are + # not visible in the test environment. + widget.hideEvent(QHideEvent()) + qtbot.wait(100) # Allow event processing + + # Widget should have yielded ownership + assert not widget.has_ownership() + page_info = _web_console_registry.get_page_info("test_console") + assert page_info.owner_gui_id is None + + +def test_web_console_show_event_takes_ownership(console_widget_with_static_id): + """Test that showEvent takes ownership when page has no owner.""" + widget = console_widget_with_static_id + + # Yield ownership + widget.yield_ownership() + assert not widget.has_ownership() + + # Show the widget again + widget.show() + + # Widget should have reclaimed ownership + assert widget.has_ownership() + assert widget.browser.isVisible() + assert not widget.overlay.isVisible() + + +def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id): + """Test that clicking on overlay takes ownership.""" + widget1, widget2 = two_console_widgets_same_id + widget1.show() + widget2.show() + + # Widget1 has ownership, widget2 doesn't + assert widget1.has_ownership() + assert not widget2.has_ownership() + assert widget1.isVisible() + assert widget1._mode == ConsoleMode.ACTIVE + assert widget2._mode == ConsoleMode.INACTIVE + + qtbot.mouseClick(widget2, Qt.MouseButton.LeftButton) + + # Widget2 should now have ownership + assert widget2.has_ownership() + assert not widget1.has_ownership() + + +def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id): + """Test that the registry cleans up pages when all widgets are removed.""" + widget = console_widget_with_static_id + + assert widget._unique_id in _web_console_registry._page_registry + + # Cleanup the widget + widget.cleanup() + + # Page should be removed from registry + assert widget._unique_id not in _web_console_registry._page_registry + + +def test_web_console_without_unique_id_no_page_sharing(console_widget): + """Test that widgets without unique_id don't participate in page sharing.""" + widget = console_widget + + # Widget should not be in the page registry + assert widget._unique_id is None + assert not widget.has_ownership() # Should return False for non-unique widgets + + +def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client): + """Test getting page info for a non-existent page.""" + page_info = _web_console_registry.get_page_info("nonexistent") + assert page_info is None + + +def test_web_console_take_ownership_without_unique_id(console_widget): + """Test that take_page_ownership fails gracefully without unique_id.""" + widget = console_widget + # Should not crash when taking ownership without unique_id + widget.take_page_ownership() + + +def test_web_console_yield_ownership_without_unique_id(console_widget): + """Test that yield_ownership fails gracefully without unique_id.""" + widget = console_widget + # Should not crash when yielding ownership without unique_id + widget.yield_ownership() + + +def test_registry_yield_ownership_gui_id_not_in_instances(): + """Test registry yield_ownership returns False when gui_id not in instances.""" + result = _web_console_registry.yield_ownership("nonexistent_gui_id") + assert result is False + + +def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id): + """Test registry yield_ownership returns False when instance weakref is dead.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + + # Store the gui_id and simulate the weakref being dead + _web_console_registry._instances[gui_id] = lambda: None + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + +def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id): + """Test registry yield_ownership returns False when page info's unique_id is None.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + unique_id = widget._unique_id + widget._unique_id = None + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + widget._unique_id = unique_id # Restore for cleanup + + +def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_with_static_id): + """Test registry yield_ownership returns False when unique_id not in page registry.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + unique_id = widget._unique_id + widget._unique_id = "nonexistent_unique_id" + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + widget._unique_id = unique_id # Restore for cleanup + + +def test_registry_owner_is_visible_page_info_none(): + """Test owner_is_visible returns False when page info doesn't exist.""" + result = _web_console_registry.owner_is_visible("nonexistent_page") + assert result is False + + +def test_registry_owner_is_visible_no_owner(console_widget_with_static_id): + """Test owner_is_visible returns False when page has no owner.""" + widget = console_widget_with_static_id + + # Yield ownership so there's no owner + widget.yield_ownership() + page_info = _web_console_registry.get_page_info(widget._unique_id) + assert page_info.owner_gui_id is None + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id): + """Test owner_is_visible returns False when owner ref doesn't exist in instances.""" + widget = console_widget_with_static_id + unique_id = widget._unique_id + + # Remove owner from instances dict + del _web_console_registry._instances[widget.gui_id] + + result = _web_console_registry.owner_is_visible(unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_instance_none(console_widget_with_static_id): + """Test owner_is_visible returns False when owner instance weakref is dead.""" + widget = console_widget_with_static_id + unique_id = widget._unique_id + gui_id = widget.gui_id + + # Simulate dead weakref + _web_console_registry._instances[gui_id] = lambda: None + + result = _web_console_registry.owner_is_visible(unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id): + """Test owner_is_visible returns True when owner is visible.""" + widget = console_widget_with_static_id + widget.show() + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is True + + +def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_id): + """Test owner_is_visible returns False when owner is not visible.""" + widget = console_widget_with_static_id + widget.hide() + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is False