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

Compare commits

..

1 Commits

Author SHA1 Message Date
df52293a90 wip demo with preview endpoint 2025-05-30 14:33:04 +02:00
326 changed files with 5744 additions and 24981 deletions

View File

@@ -1,64 +0,0 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
BEC_CORE_BRANCH:
description: 'Branch for BEC Core'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch for Ophyd Devices'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch for BEC Widgets'
required: false
default: 'main'
type: string
jobs:
bec:
name: BEC Unit Tests
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Install BEC and dependencies
uses: ./.github/actions/bec_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Run Pytest
run: |
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Run E2E Tests
uses: ./.github/actions/bec_e2e_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'

View File

@@ -57,24 +57,4 @@ jobs:
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
child-repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
uses: ./.github/workflows/end2end-conda.yml

View File

@@ -47,12 +47,4 @@ jobs:
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- name: Upload logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
retention-days: 7
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

View File

@@ -14,15 +14,10 @@ jobs:
- name: Run black and isort
run: |
pip install uv
uv pip install --system black isort
uv pip install --system -e .[dev]
pip install black isort
pip install -e .[dev]
black --check --diff --color .
isort --check --diff ./
- name: Check for disallowed imports from PySide
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
Pylint:
runs-on: ubuntu-latest

View File

@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
@@ -56,4 +56,4 @@ jobs:
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

View File

@@ -1,19 +0,0 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7

289
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,289 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
pip_args: ".[dev]"
# different stages in the pipeline
stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install -e ./[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
- echo "Pylint score is $PYLINT_SCORE"
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
needs: []
allow_failure: true
before_script:
- pip install pylint pylint-exit anybadge
- apt-get update
- apt-get install -y bc
script:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
- echo "Changed Python files:"
- $CHANGED_FILES
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
# Fail the job if the pylint score is below 9
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
stage: AdditionalTests
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- conda config --show-sources
- conda config --add channels conda-forge
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
- conda config --remove channels https://repo.anaconda.com/pkgs/main
- conda config --remove channels https://repo.anaconda.com/pkgs/r
- conda config --show-sources
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure
paths:
- ./logs/*.log
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy
needs: ["tests"]
script:
- git config --global user.name "ci_update_bot"
- git config --global user.email "ci_update_bot@bec.ch"
- git checkout "$CI_COMMIT_REF_NAME"
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
# delete all local tags
- git tag -l | xargs git tag -d
- git fetch --tags
- git tag
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
needs: ["semver"]
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.11
py-version=3.10
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=white)](https://www.python.org)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)

View File

@@ -27,11 +27,11 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
@@ -395,24 +395,20 @@ class LaunchWindow(BECMainWindow):
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindowNoRPC()
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
"""
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -420,22 +416,19 @@ class LaunchWindow(BECMainWindow):
if widget is None:
raise ValueError("No widget found in the UI file.")
# Load the UI into a widget
loader = UILoader(None)
loaded = loader.loader(ui_file)
# Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
@@ -458,7 +451,7 @@ class LaunchWindow(BECMainWindow):
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindowNoRPC()
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
@@ -549,7 +542,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 4
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""

File diff suppressed because it is too large Load Diff

View File

@@ -14,21 +14,18 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger
@@ -54,7 +51,7 @@ def _filter_output(output: str) -> str:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
@@ -154,10 +151,8 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
if client._gui_started_timer is not None:
# cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
@@ -266,37 +261,18 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait)
def show(self, wait=True) -> None:
"""
Show the GUI window.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
def show(self):
"""Show the GUI window."""
if self._check_if_server_is_alive():
return self._show_all()
return self._start(wait=wait)
return self.start(wait=True)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
def raise_window(self, wait: bool = True) -> None:
"""
Bring GUI windows to the front.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._raise_all()
return self._start(wait=wait)
def new(
self,
name: str | None = None,
@@ -406,9 +382,6 @@ class BECGuiClient(RPCBase):
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
@@ -455,8 +428,8 @@ class BECGuiClient(RPCBase):
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("show") # pylint: disable=protected-access
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -466,24 +439,11 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
if self._killed:
return
self.launcher._run_rpc("hide")
for window in self._top_level.values():
window.hide()
def _do_raise_all(self):
"""Bring GUI windows to the front."""
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("raise") # pylint: disable=protected-access
for window in self._top_level.values():
window._run_rpc("raise") # type: ignore[attr-defined]
def _raise_all(self):
with wait_for_server(self):
if self._killed:
return
return self._do_raise_all()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
def _update_dynamic_namespace(self, server_registry: dict):
"""
@@ -564,7 +524,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module
gui = BECGuiClient()
gui.show(wait=True)
gui.start(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:

View File

@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -180,10 +180,7 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -208,26 +205,14 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += """
@rpc_call"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.

View File

@@ -7,7 +7,6 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.device import DeviceBaseWithConfig
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
@@ -25,43 +24,6 @@ else:
# pylint: disable=protected-access
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
# if dev.<device> is passed to GUI, it passes full_name
if hasattr(arg, "full_name"):
return arg.full_name
elif hasattr(arg, "name"):
return arg.name
return arg
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.
@@ -85,7 +47,15 @@ def rpc_call(func):
return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back
args, kwargs = _transform_args_kwargs(args, kwargs)
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -202,11 +172,6 @@ class RPCBase:
parent = parent._parent
return parent # type: ignore
def raise_window(self):
"""Bring this widget (or its container) to the front."""
# Use explicit call to ensure action name is 'raise' (not 'raise_')
return self._run_rpc("raise")
def _run_rpc(
self,
method,
@@ -230,12 +195,6 @@ class RPCBase:
Returns:
The result of the RPC call.
"""
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
gui_id = obj.get("container_proxy") # type: ignore
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,

View File

@@ -9,7 +9,6 @@ from contextlib import redirect_stderr, redirect_stdout
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
@@ -143,8 +142,6 @@ class GUIServer:
"""
Shutdown the GUI server.
"""
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()

View File

@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": {"user device"},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
@@ -89,28 +89,16 @@ class FakePositioner(BECPositioner):
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": {"user motors"},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
}
}
self.signals = {
@@ -196,8 +184,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
@@ -222,39 +210,6 @@ class DMMock:
for device in devices:
self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

View File

@@ -161,6 +161,8 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -184,6 +186,25 @@ class BECConnector:
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
@@ -213,7 +234,7 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.sendPostedEvents()
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:

View File

@@ -38,11 +38,9 @@ def _loaded_submodules_from_specs(
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
exception_text = "".join(traceback.format_exception(e))
if "(most likely due to a circular import)" in exception_text:
logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
yield submodule
@@ -61,8 +59,7 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
and item is not BECWidget,
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)

View File

@@ -1,86 +0,0 @@
import traceback
from pathlib import Path
from typing import Annotated
import copier
import typer
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_repo_path
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
logger = bec_logger.logger
_app = typer.Typer(rich_markup_mode="rich")
def _commit_added_widget(repo: Path, name: str):
git_stage_files(repo, [".copier-answers.yml"])
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
make_commit(repo, f"plugin-manager added new widget: {name}")
logger.info(f"Committing new widget {name}")
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
return name in [w["name"] for w in widget_list]
def _editor_cb(ctx: typer.Context, value: bool):
if value and not ctx.params["use_ui"]:
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
return value
_bold_blue = "\033[34m\033[1m"
_off = "\033[0m"
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
Open the created widget .ui file in bec-designer now?"""
@_app.command()
def widget(
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
open_editor: Annotated[
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
] = True,
):
"""Create a new widget plugin with the given name.
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
bec-designer and the compiled python version will be updated when changes are made and saved."""
if (formatted_name := name.lower().replace("-", "_")) != name:
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
if not formatted_name.isidentifier():
logger.error(
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
)
exit(-1)
logger.info(f"Adding new widget {formatted_name} to the template...")
try:
repo = Path(plugin_repo_path())
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
logger.error(f"Widget {formatted_name} already exists!")
exit(-1)
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
copier.run_update(
repo,
data=plugin_data,
defaults=True,
unsafe=True,
overwrite=True,
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
)
_commit_added_widget(repo, formatted_name)
except Exception:
logger.error(traceback.format_exc())
logger.error("exiting...")
exit(-1)
logger.success(f"Added widget {formatted_name}!")
if open_editor:
open_and_watch_ui_editor(formatted_name)

View File

@@ -1,136 +0,0 @@
import re
import subprocess
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from watchdog.events import (
DirCreatedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
FileSystemEventHandler,
)
from watchdog.observers import Observer
from bec_widgets.utils.bec_designer import open_designer
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
class RecompileHandler(FileSystemEventHandler):
def __init__(self, in_file: Path, out_file: Path) -> None:
super().__init__()
self.in_file = str(in_file)
self.out_file = str(out_file)
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
self._widget_import_re = re.compile(
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
)
self._widget_modules = {
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
}
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
self.recompile(event)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
self.recompile(event)
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
self.recompile(event)
def recompile(self, event: FileSystemEvent) -> None:
if event.src_path == self.in_file or event.dest_path == self.in_file:
self._recompile()
def _recompile(self):
logger.success(".ui file modified, recompiling...")
code = subprocess.call(
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
)
logger.success(f"compilation exited with code {code}")
if code != 0:
return
self._add_comment_to_file()
logger.success("updating imports...")
self._update_imports()
logger.success("formatting...")
code = subprocess.call(
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
)
if code != 0:
logger.error(f"Error while running black on {self.out_file}, code: {code}")
return
code = subprocess.call(
[
"isort",
"--line-length=100",
"--profile=black",
"--multi-line=3",
"--trailing-comma",
self.out_file,
]
)
if code != 0:
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
return
logger.success("done!")
def _add_comment_to_file(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
f.write(
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
)
f.write(initial)
def _update_imports(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
qtpy_imports = re.sub(
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
)
print(self._widget_modules)
print(re.findall(self._widget_import_re, qtpy_imports))
widget_imports = re.sub(
self._widget_import_re,
lambda ob: (
f"from {module} import {ob.group(2)}"
if (module := self._widget_modules.get(ob.group(2))) is not None
else ob.group(1)
),
qtpy_imports,
)
f.write(widget_imports)
f.truncate()
def open_and_watch_ui_editor(widget_name: str):
logger.info(f"Opening the editor for {widget_name}, and watching")
repo = Path(plugin_repo_path())
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
ui_file = widget_dir / f"{widget_name}.ui"
ui_outfile = widget_dir / f"{widget_name}_ui.py"
logger.info(
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
)
recompile_handler = RecompileHandler(ui_file, ui_outfile)
observer = Observer()
observer.schedule(recompile_handler, str(ui_file.parent))
observer.start()
try:
open_designer([str(ui_file)])
finally:
observer.stop()
observer.join()
logger.info("Editing session ended, exiting...")

View File

@@ -1,19 +1,15 @@
from __future__ import annotations
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 QObject, Slot
from qtpy.QtWidgets import QApplication
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.rpc_decorator import rpc_timeout
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
@@ -91,7 +87,7 @@ class BECWidget(BECConnector):
theme = "dark"
self.apply_theme(theme)
@SafeSlot(str)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -100,43 +96,12 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import QLabel
class ClickableLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.clicked.emit()
return super().mouseReleaseEvent(ev)

View File

@@ -15,15 +15,12 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors | None:

View File

@@ -259,3 +259,12 @@ class CompactPopupWidget(QWidget):
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -5,13 +5,9 @@ from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, QPointF, Qt, Signal
from qtpy.QtGui import QCursor, QTransform
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
@@ -164,7 +160,7 @@ class Crosshair(QObject):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@SafeSlot(str)
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -191,7 +187,7 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@SafeSlot(int)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
@@ -209,11 +205,8 @@ class Crosshair(QObject):
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
# Handle visible items in the plot item
self.items = self.plot_item.visible_items()
else: # Non PlotBase case
# Handle all items
else:
# Handle all curves
self.items = self.plot_item.items
# Create or update markers
@@ -272,47 +265,15 @@ class Crosshair(QObject):
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
@SafeSlot()
def update_markers_on_image_change(self):
"""
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
for item in self.items:
if not isinstance(item, pg.ImageItem):
continue
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
@@ -351,29 +312,13 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.objectName() or str(id(item))
name = item.config.monitor or str(id(item))
image_2d = item.image
if image_2d is None:
continue
# Map scene coordinates (plot units) back to image pixel coordinates
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
@@ -413,74 +358,60 @@ class Crosshair(QObject):
return list_x[original_index], list_y[original_index]
@SafeSlot(object, tuple)
def mouse_moved(self, event=None, manual_pos=None):
"""
Handles the mouse moved event, updating the crosshair position and emitting signals.
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
Args:
event(object): The mouse moved event, which contains the scene position.
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
event: The mouse moved event
"""
# Determine target (x, y) in *plot* coordinates
if manual_pos is not None:
x, y = manual_pos
else:
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
# Update crosshair visuals
self.v_line.setPos(x)
self.h_line.setPos(y)
pos = event[0]
self.update_markers()
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
if snap_x_vals is None or snap_y_vals is None:
return
if all(v is None for v in snap_x_vals.values()) or all(
v is None for v in snap_y_vals.values()
):
return
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
sx, sy = snap_x_vals[name], snap_y_vals[name]
if sx is None or sy is None:
continue
self.marker_moved_1d[name].setData([sx], [sy])
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
self.coordinatesChanged1D.emit(
(name, round(sx_s, precision), round(sy_s, precision))
)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
px, py = snap_x_vals[name], snap_y_vals[name]
if px is None or py is None:
continue
# Respect image transforms
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(px, py, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
self.marker_2d_row.setPos([0, py])
self.marker_2d_col.setPos([px, 0])
self.coordinatesChanged2D.emit((name, px, py))
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -527,39 +458,19 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
@@ -601,18 +512,8 @@ class Crosshair(QObject):
image = item.image
if image is None:
continue
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
break
@@ -632,19 +533,15 @@ class Crosshair(QObject):
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
@SafeSlot()
def reset(self):
"""Resets the crosshair to its initial state."""
def cleanup(self):
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.clear_markers()
def cleanup(self):
self.reset()
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()

View File

@@ -28,10 +28,6 @@ class EntryValidator:
if not available_entries:
available_entries = [name]
# edge case for if name is passed instead of full_name, should not happen
if entry in signals_dict:
entry = signals_dict[entry].get("obj_name", entry)
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in available_entries:

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -14,20 +12,15 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
) -> None:
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
super().__init__(parent=parent)
self._expanded = expanded
@@ -36,33 +29,19 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._create_title_layout(title, icon)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
@@ -71,8 +50,7 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_expansion_icon()
self.expansion_state_changed.emit()
self._update_icon()
@SafeProperty(bool)
def expanded(self): # type: ignore
@@ -83,9 +61,8 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
self.adjustSize()
def _update_expansion_icon(self):
def _update_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
@@ -93,36 +70,3 @@ class ExpandableGroupFrame(QFrame):
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)
@SafeProperty(str)
def icon_name(self): # type: ignore
return self._title_icon_name
@icon_name.setter
def icon_name(self, icon_name: str):
self._title_icon_name = icon_name
self._set_title_icon(self._title_icon_name)
def _set_title_icon(self, icon_name: str):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
frame = ExpandableGroupFrame()
layout = QVBoxLayout()
frame.set_layout(layout)
layout.addWidget(QLabel("test1"))
layout.addWidget(QLabel("test2"))
layout.addWidget(QLabel("test3"))
frame.show()
app.exec()

View File

@@ -8,8 +8,6 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger
@@ -17,13 +15,11 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list[str | tuple]) -> None:
def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget
Args:
widget: Widget instance
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
@abstractmethod
@@ -38,37 +34,17 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection
"""
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
@@ -88,47 +64,19 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
widget.clear()
if len(selection) == 0:
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
widget.addItems(selection)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
@@ -142,40 +90,6 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
"""
return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO:
"""Public interface to set filters for input widgets.
@@ -185,14 +99,13 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
def set_selection(widget, selection: list, ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
selection(list): List of filtered selection items.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
@@ -226,35 +139,6 @@ class FilterIO:
)
return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

View File

@@ -1,107 +1,76 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import NamedTuple
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
RPC = False
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
"""
if items is not None and form_item_specs is not None:
logger.error(
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
)
items = None
if items is None and form_item_specs is None:
logger.error("Must specify one and only one of items and form_item_specs!")
items = []
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
super().__init__(parent=parent, client=client, **kwargs)
self._items = form_item_specs or [
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
for name, item_type in items # type: ignore
]
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
gl: QGridLayout = self._form_grid.layout()
gl.setRowStretch(gl.rowCount(), 1)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
@@ -109,22 +78,19 @@ class TypedForm(BECWidget, QWidget):
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1)
def enumerate_form_widgets(self):
"""Return a generator over the rows of the form, with the row number, the label widget (to
which the field name is attached as a property "_model_field_name"), and the entry widget"""
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount() - 1): # One extra row for stretch
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return {
row.label.property("_model_field_name"): row.widget.getValue()
for row in self.enumerate_form_widgets()
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
@@ -137,13 +103,10 @@ class TypedForm(BECWidget, QWidget):
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
@@ -151,61 +114,27 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm):
form_data_updated = Signal(dict)
form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
"""
A form generated from a pydantic model.
Args:
data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
self._pretty_display = pretty_display
self._md_schema = data_model
super().__init__(
parent=parent,
form_item_specs=self._form_item_specs(),
enabled=enabled,
client=client,
**kwargs,
)
self._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Validity" # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
@@ -214,40 +143,13 @@ class PydanticModelForm(TypedForm):
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
self._connect_to_theme_change()
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self):
return [
FormItemSpec(
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]
@@ -265,18 +167,16 @@ class PydanticModelForm(TypedForm):
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on form_data_cleared and returns false."""
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.form_data_updated.emit(metadata_dict)
self.validity_proc.emit(True)
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
self.metadata_cleared.emit(None)
return False

View File

@@ -1,54 +1,31 @@
from __future__ import annotations
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from typing import (
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
OrderedDict,
TypeVar,
get_args,
)
from types import UnionType
from typing import Callable, Protocol
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QRadioButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
@@ -57,7 +34,6 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen,
field_precision,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
@@ -70,36 +46,9 @@ class FormItemSpec(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias
item_type: type | UnionType
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict, set] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget):
@@ -145,20 +94,10 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent)
self._spec = spec
self._layout = QHBoxLayout()
@@ -168,17 +107,11 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
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)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
if clearable_required(spec.info):
self._add_clear_button()
@abstractmethod
def getValue(self) -> DynamicFormItemType: ...
def getValue(self): ...
@abstractmethod
def setValue(self, value): ...
@@ -188,11 +121,6 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
button.setVisible(False)
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
@@ -210,7 +138,7 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit()
class StrFormItem(DynamicFormItem):
class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
@@ -235,11 +163,11 @@ class StrFormItem(DynamicFormItem):
def setValue(self, value: str):
if value is None:
return self._main_widget.setText("")
self._main_widget.setText(str(value))
self._main_widget.setText("")
self._main_widget.setText(value)
class IntFormItem(DynamicFormItem):
class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
@@ -268,18 +196,18 @@ class IntFormItem(DynamicFormItem):
self._main_widget.setValue(value)
class FloatDecimalFormItem(DynamicFormItem):
class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, float, precision)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -296,13 +224,13 @@ class FloatDecimalFormItem(DynamicFormItem):
return self._default
return self._main_widget.value()
def setValue(self, value: float | Decimal):
def setValue(self, value: float):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(float(value))
self._main_widget.setValue(value)
class BoolFormItem(DynamicFormItem):
class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
@@ -323,314 +251,36 @@ class BoolFormItem(DynamicFormItem):
self._main_widget.setChecked(value)
class BoolToggleFormItem(BoolFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.default is PydanticUndefined:
spec.info.default = False
super().__init__(parent=parent, spec=spec)
def _add_main_widget(self) -> None:
self._main_widget = ToggleSwitch()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
if self._default is not None:
self._main_widget.setChecked(self._default)
class DictFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
_IW = TypeVar("_IW", bound=int | float | str)
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
item: type[_IW]
widget: type[QWidget]
default: _IW
class ListFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.annotation is list:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
elif isinstance(spec.info.annotation, GenericAlias):
args = set(typing.get_args(spec.info.annotation))
if args == {str}:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
if args == {int}:
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
if args == {float} or args == {int, float}:
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
else:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
super().__init__(parent=parent, spec=spec)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget: QListWidget
self._data = []
self._min_lines = 2 if spec.pretty_display else 4
self._repop(self._data)
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
self._layout.addWidget(self._main_widget)
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self._add_buttons()
def _add_buttons(self):
self._button_holder = QWidget()
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._buttons = QVBoxLayout()
self._buttons.setContentsMargins(0, 0, 0, 0)
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_remove_button_holder = QWidget()
self._add_remove_button_layout = QHBoxLayout()
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
self._add_button = QPushButton("+")
self._add_button.setMinimumHeight(15)
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setMinimumHeight(15)
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_remove_button_holder)
self._add_remove_button_layout.addWidget(self._add_button)
self._add_remove_button_layout.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()
self._button_holder.setHidden(True)
def _repop(self, data):
self._main_widget.clear()
for val in data:
self._add_list_item(val)
self.scale_to_data()
def _add_data_item(self, val=None):
val = val or self._types.default
self._data.append(val)
self._add_list_item(val)
self._repop(self._data)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
WidgetIO.connect_widget_change_signal(item_widget, self._update)
return item_widget
def _update(self, _, value, *args):
self._data[self._main_widget.currentRow()] = value
@SafeSlot()
def _add_row(self):
self._add_data_item(self._types.default)
self._repop(self._data)
@SafeSlot()
def _delete_row(self):
if selected := self._main_widget.currentItem():
self._main_widget.removeItemWidget(selected)
row = self._main_widget.currentRow()
self._main_widget.takeItem(row)
self._data.pop(row)
self._repop(self._data)
@SafeSlot()
def clear(self):
self._repop([])
def getValue(self):
return self._data
def setValue(self, value: Iterable):
if set(map(type, value)) | {self._types.item} != {self._types.item}:
raise ValueError(f"This widget only accepts items of type {self._types.item}")
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
class SetFormItem(ListFormItem):
def _add_main_widget(self) -> None:
super()._add_main_widget()
self._add_item_field = self._types.widget()
self._buttons.addWidget(QLabel("Add new:"))
self._buttons.addWidget(self._add_item_field)
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
@SafeSlot()
def _add_row(self):
self._add_data_item(WidgetIO.get_value(self._add_item_field))
self._repop(self._data)
def _update(self, _, value, *args):
if value in self._data:
return
return super()._update(_, value, *args)
def _add_data_item(self, val=None):
val = val or self._types.default
if val == self._types.default or val in self._data:
return
self._data.append(val)
self._add_list_item(val)
def _add_list_item(self, val):
item_widget = super()._add_list_item(val)
if isinstance(item_widget, QLineEdit):
item_widget.setReadOnly(True)
return item_widget
def getValue(self):
return set(self._data)
def setValue(self, value: set):
return super().setValue(set(value))
class StrLiteralFormItem(DynamicFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(self._spec.info.annotation)
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
return
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
def clear(self):
self._main_widget.setCurrentIndex(-1)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
"float_decimal": (
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
FloatDecimalFormItem,
),
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
"dict": (
lambda spec: spec.item_type in [dict, dict | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
DictFormItem,
),
"list": (
lambda spec: spec.item_type in [list, list | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
ListFormItem,
),
"set": (
lambda spec: spec.item_type in [set, set | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
SetFormItem,
),
}
def widget_from_type(
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
) -> type[DynamicFormItem]:
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)
return StrFormItem
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField
if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel):
value0: set = Field(set(["a", "b"]))
value1: str | None = Field(None)
value2: bool | None = Field(None)
value3: bool = Field(True)
value4: int = Field(123)
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)
layout.addWidget(widg, i, 1)
items[6].setValue([1, 2, 3, 4])
items[7].setValue(["1", "2", "asdfg", "qwerty"])
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
w.show()
app.exec()

View File

@@ -1,21 +0,0 @@
import bec_qthemes
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
QRadioButton {{ color: {foreground}; }}
QRadioButton::indicator::checked {{ color: {accent}; }}
QCheckBox {{ color: {accent}; }}
"""
if __name__ == "__main__":
print(pretty_display_theme())

View File

@@ -7,10 +7,7 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
class PluginFilenames(NamedTuple):
@@ -93,20 +90,34 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
cls_init_found = class_re.search(init_source) is not None
super_self_re = re.compile(
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
)
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found:
raise ValueError(

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import}
@@ -21,8 +20,6 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = {plugin_name_pascal}(parent)
return t

View File

@@ -201,7 +201,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)

View File

@@ -1,694 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())

View File

@@ -13,17 +13,3 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -230,8 +229,6 @@ class RPCServer:
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
logger.info(f"Waiting for {connector} to be registered...")
time.sleep(0.1)
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:

View File

@@ -1,5 +1,5 @@
from bec_lib.logger import bec_logger
from qtpy.QtGui import QCloseEvent
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot

View File

@@ -16,8 +16,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
@@ -62,7 +61,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -93,7 +92,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -289,16 +288,8 @@ class SidePanel(QWidget):
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
)
self.toolbar.components.add_safe(action_id, action)
bundle = ToolbarBundle(action_id, self.toolbar.components)
bundle.add_action(action_id)
self.toolbar.add_bundle(bundle)
shown_bundles = self.toolbar.shown_bundles
shown_bundles.append(action_id)
self.toolbar.show_bundles(shown_bundles)
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:

1088
bec_widgets/utils/toolbar.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,524 +0,0 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
from bec_lib.device import ReadoutPriority
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.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QSizePolicy,
QStyledItemDelegate,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
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
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
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
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = 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
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
def cleanup(self):
"""Cleans up the action, if necessary."""
pass
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
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):
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)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
"""
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.
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.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.filled = filled
self.color = color
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
def get_icon(self):
"""
Returns the icon for the action.
Returns:
QIcon: The icon for the action.
"""
return self.icon
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.
"""
def __init__(self, label: str | None = None, device_combobox=None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget(parent=target)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(text=f"{self.label}", parent=target)
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.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.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.main_button.setMenu(menu)
if self.default_state_checked:
self.main_button.setChecked(True)
self.action = toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
"""
Triggers the current action associated with the main button.
"""
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
"""
Sets the default action for the split action.
Args:
key(str): The key of the action to set as default.
"""
if self.main_button is None:
return
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
new_action.action.trigger()
# Active action chosen from menu is always checked, uncheck through main button
if self.checkable:
new_action.action.setChecked(True)
self.main_button.setChecked(True)
def block_all_signals(self, block: bool = True):
"""
Blocks or unblocks all signals for the actions in the toolbar.
Args:
block (bool): Whether to block signals. Defaults to True.
"""
if self.main_button is not None:
self.main_button.blockSignals(block)
for action in self.actions.values():
action.action.blockSignals(block)
@contextmanager
def signal_blocker(self):
"""
Context manager to block signals for all actions in the toolbar.
"""
self.block_all_signals(True)
try:
yield
finally:
self.block_all_signals(False)
def set_state_all(self, state: bool):
"""
Uncheck all actions in the toolbar.
"""
for action in self.actions.values():
action.action.setChecked(state)
if self.main_button is None:
return
self.main_button.setChecked(state)
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
Please note that the injected widget must be life-cycled by the parent widget,
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
will not perform any cleanup on the widget itself, only on the container that holds it.
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
"""
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
self.adjust_size = adjust_size
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.
"""
self.container = QWidget(parent=target)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
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)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
layout.addWidget(self.widget)
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
def cleanup(self):
"""
Cleans up the action by closing and deleting the container widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.container is not None:
self.container.close()
self.container.deleteLater()
return super().cleanup()
@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()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
super().__init__(icon_path, label)
self.actions = actions
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_container in self.actions.values():
action: QAction = action_container.action
action.setIconVisibleInMenu(True)
if action_container.icon_path:
icon = QIcon()
icon.addFile(action_container.icon_path, size=QSize(20, 20))
action.setIcon(icon)
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
sub_icon = action_container.get_icon()
if sub_icon and not sub_icon.isNull():
action.setIcon(sub_icon)
action.setCheckable(action_container.checkable)
menu.addAction(action)
button.setMenu(menu)
toolbar.addWidget(button)
class DeviceComboBoxAction(WidgetAction):
"""
Action for a device selection combobox in the toolbar.
Args:
label (str): The label for the combobox.
device_combobox (QComboBox): The combobox for selecting the device.
"""
def __init__(
self,
target_widget: QWidget,
device_filter: list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
tooltip: str | None = None,
add_empty_item: bool = False,
no_check_delegate: bool = False,
):
self.combobox = DeviceComboBox(
parent=target_widget,
device_filter=device_filter,
readout_priority_filter=readout_priority_filter,
)
super().__init__(widget=self.combobox, adjust_size=False)
if add_empty_item:
self.combobox.addItem("", None)
self.combobox.setCurrentText("")
if tooltip is not None:
self.combobox.setToolTip(tooltip)
if no_check_delegate:
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
def cleanup(self):
"""
Cleans up the action by closing and deleting the combobox widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.combobox is not None:
self.combobox.close()
self.combobox.deleteLater()
return super().cleanup()

View File

@@ -1,244 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING, DefaultDict
from weakref import ReferenceType
import louie
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class ActionInfo(BaseModel):
action: ToolBarAction
toolbar_bundle: ToolbarBundle | None = None
model_config = {"arbitrary_types_allowed": True}
class ToolbarComponents:
def __init__(self, toolbar: ModularToolBar):
"""
Initializes the toolbar components.
Args:
toolbar (ModularToolBar): The toolbar to which the components will be added.
"""
self.toolbar = toolbar
self._components: dict[str, ActionInfo] = {}
self.add("separator", SeparatorAction())
def add(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar.
Args:
component (ToolBarAction): The component to add.
"""
if name in self._components:
raise ValueError(f"Component with name '{name}' already exists.")
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
def add_safe(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar, ensuring it does not already exist.
Args:
name (str): The name of the component.
component (ToolBarAction): The component to add.
"""
if self.exists(name):
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
return
self.add(name, component)
def exists(self, name: str) -> bool:
"""
Checks if a component exists in the toolbar.
Args:
name (str): The name of the component to check.
Returns:
bool: True if the component exists, False otherwise.
"""
return name in self._components
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
return louie.saferef.safe_ref(self._components[name].action)
def get_action(self, name: str) -> ToolBarAction:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
Returns:
ToolBarAction: The action associated with the given name.
"""
if not self.exists(name):
raise KeyError(
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
)
return self._components[name].action
def set_bundle(self, name: str, bundle: ToolbarBundle):
"""
Sets the bundle for a component.
Args:
name (str): The name of the component.
bundle (ToolbarBundle): The bundle to set.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
comp = self._components[name]
if comp.toolbar_bundle is not None:
logger.info(
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
)
comp.toolbar_bundle.bundle_actions.pop(name, None)
comp.toolbar_bundle = bundle
def remove_action(self, name: str):
"""
Removes a component from the toolbar.
Args:
name (str): The name of the component to remove.
"""
if not self.exists(name):
raise KeyError(f"Action with ID '{name}' does not exist.")
action_info = self._components.pop(name)
if action_info.toolbar_bundle:
action_info.toolbar_bundle.bundle_actions.pop(name, None)
self.toolbar.refresh()
action_info.toolbar_bundle = None
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
def cleanup(self):
"""
Cleans up the toolbar components by removing all actions and bundles.
"""
for action_info in self._components.values():
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
self._components.clear()
class ToolbarBundle:
def __init__(self, name: str, components: ToolbarComponents):
"""
Initializes a new bundle component.
Args:
bundle_name (str): Unique identifier for the bundle.
"""
self.name = name
self.components = components
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
self._connections: dict[str, BundleConnection] = {}
def add_action(self, name: str):
"""
Adds an action to the bundle.
Args:
name (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
if name in self.bundle_actions:
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
if not self.components.exists(name):
raise ValueError(
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
)
self.bundle_actions[name] = self.components.get_action_reference(name)
self.components.set_bundle(name, self)
def remove_action(self, name: str):
"""
Removes an action from the bundle.
Args:
name (str): The name of the action to remove.
"""
if name not in self.bundle_actions:
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
del self.bundle_actions[name]
def add_separator(self):
"""
Adds a separator action to the bundle.
"""
self.add_action("separator")
def add_connection(self, name: str, connection):
"""
Adds a connection to the bundle.
Args:
name (str): Unique identifier for the connection.
connection: The connection to add.
"""
if name in self._connections:
raise ValueError(
f"Connection with name '{name}' already exists in bundle '{self.name}'."
)
self._connections[name] = connection
def remove_connection(self, name: str):
"""
Removes a connection from the bundle.
Args:
name (str): The name of the connection to remove.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
self._connections[name].disconnect()
del self._connections[name]
def get_connection(self, name: str):
"""
Retrieves a connection by name.
Args:
name (str): The name of the connection to retrieve.
Returns:
The connection associated with the given name.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
return self._connections[name]
def disconnect(self):
"""
Disconnects all connections in the bundle.
"""
for connection in self._connections.values():
connection.disconnect()
self._connections.clear()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from qtpy.QtCore import QObject
class BundleConnection(QObject):
bundle_name: str
@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.
"""
@abstractmethod
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.
"""

View File

@@ -1,64 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
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.connections import BundleConnection
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a performance toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The performance toolbar bundle.
"""
components.add_safe(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
),
)
bundle = ToolbarBundle("performance", components)
bundle.add_action("fps_monitor")
return bundle
class PerformanceConnection(BundleConnection):
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "performance"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "enable_fps_monitor"):
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
super().__init__()
self._connected = False
@SafeSlot(bool)
def set_fps_monitor(self, enabled: bool):
setattr(self.target_widget, "enable_fps_monitor", enabled)
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
self.set_fps_monitor
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
self.set_fps_monitor
)
self._connected = False

View File

@@ -1,513 +0,0 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import sys
from collections import defaultdict
from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
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 bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
logger = bec_logger.logger
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
"""
def __init__(
self,
parent=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent=parent)
self.background_color = background_color
self.set_background_color(self.background_color)
# Set the initial orientation
self.set_orientation(orientation)
self.components = ToolbarComponents(self)
# Initialize bundles
self.bundles: dict[str, ToolbarBundle] = {}
self.shown_bundles: list[str] = []
#########################
# outdated items... remove
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
########################
def new_bundle(self, name: str) -> ToolbarBundle:
"""
Creates a new bundle component.
Args:
name (str): Unique identifier for the bundle.
Returns:
ToolbarBundle: The new bundle component.
"""
if name in self.bundles:
raise ValueError(f"Bundle with name '{name}' already exists.")
bundle = ToolbarBundle(name=name, components=self.components)
self.bundles[name] = bundle
return bundle
def add_bundle(self, bundle: ToolbarBundle):
"""
Adds a bundle component to the toolbar.
Args:
bundle (ToolbarBundle): The bundle component to add.
"""
if bundle.name in self.bundles:
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
self.bundles[bundle.name] = bundle
if not bundle.bundle_actions:
logger.warning(f"Bundle '{bundle.name}' has no actions.")
def remove_bundle(self, name: str):
"""
Removes a bundle component by name.
Args:
name (str): The name of the bundle to remove.
"""
if name not in self.bundles:
raise KeyError(f"Bundle with name '{name}' does not exist.")
del self.bundles[name]
if name in self.shown_bundles:
self.shown_bundles.remove(name)
logger.info(f"Bundle '{name}' removed from the toolbar.")
def get_bundle(self, name: str) -> ToolbarBundle:
"""
Retrieves a bundle component by name.
Args:
name (str): The name of the bundle to retrieve.
Returns:
ToolbarBundle: The bundle component.
"""
if name not in self.bundles:
raise KeyError(
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
)
return self.bundles[name]
def show_bundles(self, bundle_names: list[str]):
"""
Sets the bundles to be shown for the toolbar.
Args:
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
"""
self.clear()
for requested_bundle in bundle_names:
bundle = self.get_bundle(requested_bundle)
for bundle_action in bundle.bundle_actions.values():
action = bundle_action()
if action is None:
logger.warning(
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
)
continue
action.add_to_toolbar(self, self.parent())
separator = self.components.get_action_reference("separator")()
if separator is not None:
separator.add_to_toolbar(self, self.parent())
self.update_separators() # Ensure separators are updated after showing bundles
self.shown_bundles = bundle_names
def add_action(self, action_name: str, action: ToolBarAction):
"""
Adds a single action to the toolbar. It will create a new bundle
with the same name as the action.
Args:
action_name (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
self.components.add_safe(action_name, action)
bundle = ToolbarBundle(name=action_name, components=self.components)
bundle.add_action(action_name)
self.add_bundle(bundle)
def hide_action(self, action_name: str):
"""
Hides a specific action in the toolbar.
Args:
action_name (str): Unique identifier for the action to hide.
"""
action = self.components.get_action(action_name)
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(False)
self.update_separators()
def show_action(self, action_name: str):
"""
Shows a specific action in the toolbar.
Args:
action_name (str): Unique identifier for the action to show.
"""
action = self.components.get_action(action_name)
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(True)
self.update_separators()
@property
def toolbar_actions(self) -> list[ToolBarAction]:
"""
Returns a list of all actions currently in the toolbar.
Returns:
list[ToolBarAction]: List of actions in the toolbar.
"""
actions = []
for bundle in self.shown_bundles:
if bundle not in self.bundles:
continue
for action in self.bundles[bundle].bundle_actions.values():
action_instance = action()
if action_instance is not None:
actions.append(action_instance)
return actions
def refresh(self):
"""Refreshes the toolbar by clearing and re-populating it."""
self.clear()
self.show_bundles(self.shown_bundles)
def connect_bundle(self, connection_name: str, connector: BundleConnection):
"""
Connects a bundle to a target widget or application.
Args:
bundle_name (str): The name of the bundle to connect.
connector (BundleConnection): The connector instance that implements the connection logic.
"""
bundle_name = connector.bundle_name
if bundle_name not in self.bundles:
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
connector.connect()
self.bundles[bundle_name].add_connection(connection_name, connector)
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
"""
Disconnects a bundle connection.
Args:
bundle_name (str): The name of the bundle to disconnect.
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
"""
if bundle_name not in self.bundles:
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
bundle = self.bundles[bundle_name]
if connection_name is None:
# Disconnect all connections in the bundle
bundle.disconnect()
else:
bundle.remove_connection(name=connection_name)
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
Sets the background color and other appearance settings.
Args:
color (str): The background color of the toolbar.
"""
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
self.background_color = color
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
"""Sets the orientation of the toolbar.
Args:
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
"""
if orientation == "horizontal":
self.setOrientation(Qt.Horizontal)
elif orientation == "vertical":
self.setOrientation(Qt.Vertical)
else:
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons.
Args:
new_color (str | tuple | QColor): The new color.
"""
for action in self.available_widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.
Args:
event (QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
theme = get_theme_name()
if theme == "dark":
menu.setStyleSheet(
"""
QMenu {
background-color: rgba(50, 50, 50, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
}
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
else:
# Light theme styling
menu.setStyleSheet(
"""
QMenu {
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.2);
}
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
for ii, bundle in enumerate(self.shown_bundles):
self.handle_bundle_context_menu(menu, bundle)
if ii < len(self.shown_bundles) - 1:
menu.addSeparator()
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds bundle actions to the context menu.
Args:
menu (QMenu): The context menu.
bundle_id (str): The bundle identifier.
"""
bundle = self.bundles.get(bundle_id)
if not bundle:
return
for act_id in bundle.bundle_actions:
toolbar_action = self.components.get_action(act_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
toolbar_action, "action"
):
continue
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
continue
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
def _add_qaction_to_menu(
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
):
display_name = qaction.text() or toolbar_action.tooltip or act_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(act_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
"""
Adds a single toolbar action to the context menu.
Args:
menu (QMenu): The context menu to which the action is added.
action_id (str): Unique identifier for the action.
"""
toolbar_action = self.available_widgets.get(action_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
return
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
return
display_name = qaction.text() or toolbar_action.tooltip or action_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setIconVisibleInMenu(True)
menu_action.setData(action_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""
Handles the triggered signal from the context menu.
Args:
action: Action triggered.
"""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id)
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
"""
Toggles the visibility of a specific action.
Args:
action_id (str): Unique identifier.
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
"""
if not self.components.exists(action_id):
return
tool_action = self.components.get_action(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
if visible is None:
visible = not tool_action.action.isVisible()
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
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()
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
prev = action
if not toolbar_actions:
return
# Make sure the first visible action is not a separator
for i, action in enumerate(toolbar_actions):
if action.isVisible():
if action.isSeparator():
action.setVisible(False)
break
# Make sure the last visible action is not a separator
for i, action in enumerate(reversed(toolbar_actions)):
if action.isVisible():
if action.isSeparator():
action.setVisible(False)
break
def cleanup(self):
"""
Cleans up the toolbar by removing all actions and bundles.
"""
# First, disconnect all bundles
for bundle_name in list(self.bundles.keys()):
self.disconnect_bundle(bundle_name)
# Clear all components
self.components.cleanup()
self.bundles.clear()
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
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.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
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.get_bundle("performance").add_action("save")
self.toolbar.refresh()
def enable_fps_monitor(self, enabled: bool):
"""
Example method to enable or disable FPS monitoring.
This method should be implemented in the target widget.
"""
if enabled:
self.test_label.setText("FPS Monitor Enabled")
else:
self.test_label.setText("FPS Monitor Disabled")
app = QApplication(sys.argv)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from qtpy.QtUiTools import QUiLoader
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None):

View File

@@ -264,48 +264,6 @@ class WidgetIO:
return WidgetIO._handlers[base]
return None
@staticmethod
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
"""
Return widgets matching the given class (or class-name string).
Args:
widget_class: Either a QWidget subclass or its class-name as a string.
recursive: If True (default), traverse all top-level widgets and their children;
if False, scan app.allWidgets() for a flat list.
Returns:
List of QWidget instances matching the class or class-name.
"""
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found.")
# Match by class-name string
if isinstance(widget_class, str):
name = widget_class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if top.__class__.__name__ == name:
result.append(top)
result.extend(
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
)
return result
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
# Match by actual class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if isinstance(top, widget_class):
result.append(top)
result.extend(top.findChildren(widget_class))
return result
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
################## for exporting and importing widget hierarchies ##################

View File

@@ -27,7 +27,6 @@ class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
PLUGIN = False
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"

View File

@@ -1 +0,0 @@
{'files': ['dock_area.py']}

View File

@@ -389,7 +389,6 @@ class BECDock(BECWidget, Dock):
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
widget.deleteLater()
def delete_all(self):
"""

View File

@@ -15,20 +15,18 @@ 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 (
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
ModularToolBar,
SeparatorAction,
)
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
@@ -71,7 +69,6 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
@@ -107,238 +104,145 @@ class BECDockArea(BECWidget, QWidget):
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(parent=self)
self._setup_toolbar()
self.toolbar = ModularToolBar(
parent=self,
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
),
},
),
"separator_0": SeparatorAction(),
"menu_devices": ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
),
},
),
"separator_1": SeparatorAction(),
"menu_utils": ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
),
},
),
"separator_2": SeparatorAction(),
"attach_all": MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks"
),
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
"restore_state": MaterialIconAction(
icon_name="frame_reload", tooltip="Restore Dock State"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
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,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel",
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(
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
menu_plots.actions["scatter_waveform"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
menu_plots.actions["multi_waveform"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
)
menu_plots.actions["image"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
menu_plots.actions["motor_map"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["motor_map"].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(
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
menu_devices.actions["positioner_box"].action.triggered.connect(
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
menu_utils.actions["queue"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
menu_utils.actions["status"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
menu_utils.actions["vs_code"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
menu_utils.actions["progress_bar"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
menu_utils.actions["log_panel"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
menu_utils.actions["sbb_monitor"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# FIXME temporarily disabled -> issue #644
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
# Icons
self.toolbar.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)
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:

View File

@@ -0,0 +1 @@
{'files': ['dock_area.py','dock.py']}

View File

@@ -1,19 +1,22 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
DOM_XML = """
<ui language='c++'>
<widget class='BECDockArea' name='bec_dock_area'>
<widget class='BECDockArea' name='dock_area'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BECDockArea(parent)
return t
@@ -30,13 +31,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Containers"
return "BEC Plots"
def icon(self):
return designer_material_icon(BECDockArea.ICON_NAME)
def includeFile(self):
return "bec_dock_area"
return "dock_area"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -51,7 +52,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BECDockArea"
def toolTip(self):
return ""
return "BECDockArea"
def whatsThis(self):
return self.toolTip()

View File

@@ -6,7 +6,7 @@ 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.containers.dock.dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())

View File

@@ -1,204 +0,0 @@
from __future__ import annotations
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 bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
class CollapsibleSection(QWidget):
"""A widget that combines a header button with any content widget for collapsible sections
This widget contains a header button with a title and a content widget.
The content widget can be any QWidget. The header button can be expanded or collapsed.
The header also contains an "Add" button that is only visible when hovering over the section.
Signals:
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
onto another section for reordering.
Arguments are (source_title, target_title).
"""
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
self.setAcceptDrops(True)
self._expanded = True
# Setup layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
self.main_layout.setSpacing(0)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 4, 0)
header_layout.setSpacing(0)
# Create header button
self.header_button = QPushButton()
self.header_button.clicked.connect(self.toggle_expanded)
# Enable drag and drop for reordering
self.header_button.setAcceptDrops(True)
self.header_button.mousePressEvent = self._header_mouse_press_event
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
self.drag_start_position = None
# Add header to layout
header_layout.addWidget(self.header_button)
header_layout.addStretch()
self.header_add_button = QPushButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
self._update_expanded_state()
def set_widget(self, widget):
"""Set the content widget for this collapsible section"""
# Remove existing content widget if any
if self.content_widget and self.content_widget.parent() == self:
self.main_layout.removeWidget(self.content_widget)
self.content_widget.close()
self.content_widget.deleteLater()
self.content_widget = widget
if self.content_widget:
self.main_layout.addWidget(self.content_widget)
self._update_expanded_state()
def _update_appearance(self):
"""Update the header button appearance based on expanded state"""
# Use material icons with consistent sizing to match tree items
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
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
self._update_expanded_state()
def _update_expanded_state(self):
"""Update the expanded state based on current state"""
self._update_appearance()
if self.expanded:
if self.content_widget:
self.content_widget.show()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
else:
if self.content_widget:
self.content_widget.hide()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@SafeProperty(bool)
def expanded(self) -> bool:
"""Get the expanded state"""
return self._expanded
@expanded.setter
def expanded(self, value: bool):
"""Set the expanded state programmatically"""
if not isinstance(value, bool):
raise ValueError("Expanded state must be a boolean")
if self._expanded == value:
return
self._expanded = value
self._update_appearance()
def connect_add_button(self, slot):
"""Connect a slot to the add button's clicked signal.
Args:
slot: The function to call when the add button is clicked.
"""
self.header_add_button.clicked.connect(slot)
def _header_mouse_press_event(self, event):
"""Handle mouse press on header for drag start"""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.pos()
QPushButton.mousePressEvent(self.header_button, event)
def _header_mouse_move_event(self, event):
"""Handle mouse move to start drag operation"""
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
# Check if we've moved far enough to start a drag
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
self._start_drag()
QPushButton.mouseMoveEvent(self.header_button, event)
def _start_drag(self):
"""Start the drag operation with a properly aligned widget pixmap"""
drag = QDrag(self.header_button)
mime_data = QMimeData()
mime_data.setText(f"section:{self.title}")
drag.setMimeData(mime_data)
# Grab a pixmap of the widget
widget_pixmap = self.header_button.grab()
drag.setPixmap(widget_pixmap)
# Set the hotspot to where the mouse was pressed on the widget
drag.setHotSpot(self.drag_start_position)
drag.exec_(Qt.MoveAction)
def _header_drag_enter_event(self, event):
"""Handle drag enter on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
event.acceptProposedAction()
else:
event.ignore()
def _header_drop_event(self, event):
"""Handle drop on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
source_title = event.mimeData().text().replace("section:", "")
if source_title != self.title:
# Emit signal to parent to handle reordering
self.section_reorder_requested.emit(source_title, self.title)
event.acceptProposedAction()
else:
event.ignore()

View File

@@ -1,179 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
class Explorer(BECWidget, QWidget):
"""
A widget that combines multiple collapsible sections for an explorer-like interface.
Each section can be expanded or collapsed, and sections can be reordered. The explorer
can contain also sub-explorers for nested structures.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# Splitter for sections
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.main_layout.addWidget(self.splitter)
# Spacer for when all sections are collapsed
self.expander = QSpacerItem(0, 0)
self.main_layout.addItem(self.expander)
# Registry of sections
self.sections: list[CollapsibleSection] = []
# Setup splitter styling
self._setup_splitter_styling()
def add_section(self, section: CollapsibleSection) -> None:
"""
Add a collapsible section to the explorer
Args:
section (CollapsibleSection): The section to add
"""
if not isinstance(section, CollapsibleSection):
raise TypeError("section must be an instance of CollapsibleSection")
if section in self.sections:
return
self.sections.append(section)
self.splitter.addWidget(section)
# Connect the section's toggle to update spacer
section.header_button.clicked.connect(self._update_spacer)
# Connect section reordering if supported
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.connect(self._handle_section_reorder)
self._update_spacer()
def remove_section(self, section: CollapsibleSection) -> None:
"""
Remove a collapsible section from the explorer
Args:
section (CollapsibleSection): The section to remove
"""
if section not in self.sections:
return
self.sections.remove(section)
section.deleteLater()
section.close()
# Disconnect signals
try:
section.header_button.clicked.disconnect(self._update_spacer)
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.disconnect(self._handle_section_reorder)
except RuntimeError:
# Signals already disconnected
pass
self._update_spacer()
def get_section(self, title: str) -> CollapsibleSection | None:
"""Get a section by its title"""
for section in self.sections:
if section.title == title:
return section
return None
def _setup_splitter_styling(self) -> None:
"""Setup the splitter styling with theme colors"""
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
QSplitter::handle {{
height: 0.1px;
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
}}
"""
)
def _update_spacer(self) -> None:
"""Update the spacer size based on section states"""
any_expanded = any(section.expanded for section in self.sections)
if any_expanded:
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else:
self.expander.changeSize(
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
)
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
"""Handle reordering of sections"""
if source_title == target_title:
return
source_section = self.get_section(source_title)
target_section = self.get_section(target_title)
if not source_section or not target_section:
return
# Get current indices
source_index = self.splitter.indexOf(source_section)
target_index = self.splitter.indexOf(target_section)
if source_index == -1 or target_index == -1:
return
# Insert at target position
self.splitter.insertWidget(target_index, source_section)
# Update sections
self.sections.remove(source_section)
self.sections.insert(target_index, source_section)
if __name__ == "__main__":
import os
from qtpy.QtWidgets import QApplication, QLabel
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
app = QApplication([])
explorer = Explorer()
section = CollapsibleSection(title="SCRIPTS", indentation=0)
script_explorer = Explorer()
script_widget = ScriptTreeWidget()
local_scripts_section = CollapsibleSection(title="Local")
local_scripts_section.set_widget(script_widget)
script_widget.set_directory(os.path.abspath("./"))
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
explorer.add_section(section)
shared_script_section = CollapsibleSection(title="Shared")
shared_script_widget = ScriptTreeWidget()
shared_script_widget.set_directory(os.path.abspath("./"))
shared_script_section.set_widget(shared_script_widget)
script_explorer.add_section(shared_script_section)
macros_section = CollapsibleSection(title="MACROS", indentation=0)
macros_section.set_widget(QLabel("Macros will be implemented later"))
explorer.add_section(macros_section)
explorer.show()
app.exec()

View File

@@ -1,387 +0,0 @@
import os
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 bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""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 add_file_action(self, action: QAction) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
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
if not isinstance(model, QSortFilterProxyModel):
return super().editorEvent(event, model, option, index)
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)
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
class ScriptTreeWidget(QWidget):
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
file_selected = Signal(str) # Script file path selected
file_open_requested = Signal(str) # File open button clicked
file_renamed = Signal(str, str) # Old path, new 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)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)
# Create and set custom delegate
self.delegate = FileItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for files
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
action.action.triggered.connect(self._on_file_open_requested)
self.delegate.add_file_action(action.action)
# Remove unnecessary columns
self.tree.setColumnHidden(1, True) # Hide size column
self.tree.setColumnHidden(2, True) # Hide type column
self.tree.setColumnHidden(3, True) # Hide date modified column
# Apply BEC styling
self._apply_styling()
# Script 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)
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
}}
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 scripts directory"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
self.tree.expandAll()
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
path_obj = Path(file_path)
# Only emit signal for Python files
if path_obj.suffix.lower() == ".py":
logger.info(f"Script selected: {file_path}")
self.file_selected.emit(file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
# Emit signal to open the file
logger.info(f"File open requested via double-click: {file_path}")
self.file_open_requested.emit(file_path)
def _on_file_open_requested(self):
"""Handle file open action triggered"""
logger.info("File open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_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.model.setRootPath("") # Reset
root_index = self.model.setRootPath(self.directory)
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_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()

View File

@@ -1,115 +0,0 @@
import sys
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
class HoverWidget(QWidget):
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
super().__init__(parent)
self._simple = simple
self._full = full
self._full.setVisible(False)
self._tooltip = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(simple)
def enterEvent(self, event):
# suppress empty-label tooltips for labels
if isinstance(self._full, QLabel) and not self._full.text():
return
if self._tooltip is None: # first time only
self._tooltip = WidgetTooltip(self._full)
self._full.setVisible(True)
centre = self.mapToGlobal(self.rect().center())
self._tooltip.show_above(centre)
super().enterEvent(event)
def leaveEvent(self, event):
if self._tooltip and self._tooltip.isVisible():
self._tooltip.hide()
super().leaveEvent(event)
def close(self):
if self._tooltip:
self._tooltip.close()
self._tooltip.deleteLater()
self._tooltip = None
super().close()
################################################################################
# Demo
# Just a simple example to show how the HoverWidget can be used to display
# a tooltip with a full widget inside (two different widgets are used
# for the simple and full versions).
################################################################################
class DemoSimpleWidget(QLabel): # pragma: no cover
"""A simple widget to be used as a trigger for the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setText("Hover me for a preview!")
class DemoFullWidget(QProgressBar): # pragma: no cover
"""A full widget to be shown in the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setRange(0, 100)
self.setValue(75)
self.setFixedWidth(320)
self.setFixedHeight(30)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = QWidget()
window.layout = QHBoxLayout(window)
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
window.layout.addWidget(hover_widget)
window.show()
sys.exit(app.exec_())

View File

@@ -1,110 +0,0 @@
from qtpy.QtCore import QTimer
from qtpy.QtGui import QFontMetrics, QPainter
from qtpy.QtWidgets import QLabel
class ScrollLabel(QLabel):
"""A QLabel that scrolls its text horizontally across the widget."""
def __init__(self, parent=None, speed_ms=30, step_px=1, delay_ms=2000):
super().__init__(parent=parent)
self._offset = 0
self._text_width = 0
# scrolling timer (runs continuously once started)
self._timer = QTimer(self)
self._timer.setInterval(speed_ms)
self._timer.timeout.connect(self._scroll)
# delaybeforescroll timer (singleshot)
self._delay_timer = QTimer(self)
self._delay_timer.setSingleShot(True)
self._delay_timer.setInterval(delay_ms)
self._delay_timer.timeout.connect(self._timer.start)
self._step_px = step_px
def setText(self, text):
"""
Overridden to ensure that new text replaces the current one
immediately.
If the label was already scrolling (or in its delay phase),
the next message starts **without** the extra delay.
"""
# Determine whether the widget was already in a scrolling cycle
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
super().setText(text)
fm = QFontMetrics(self.font())
self._text_width = fm.horizontalAdvance(text)
self._offset = 0
# Skip the delay when we were already scrolling
self._update_timer(skip_delay=was_scrolling)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_timer()
def _update_timer(self, *, skip_delay: bool = False):
"""
Decide whether to start or stop scrolling.
If the text is wider than the visible area, start a singleshot
delay timer (2s by default). Scrolling begins only after this
delay. Any change (resize or new text) restarts the logic.
"""
needs_scroll = self._text_width > self.width()
if needs_scroll:
# Reset any running timers
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
self._offset = 0
# Start scrolling immediately when we should skip the delay,
# otherwise apply the configured delay_ms interval
if skip_delay:
self._timer.start()
else:
self._delay_timer.start()
else:
if self._delay_timer.isActive():
self._delay_timer.stop()
if self._timer.isActive():
self._timer.stop()
self.update()
def _scroll(self):
self._offset += self._step_px
if self._offset >= self._text_width:
self._offset = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
text = self.text()
if not text:
return
fm = QFontMetrics(self.font())
y = (self.height() + fm.ascent() - fm.descent()) // 2
if self._text_width <= self.width():
painter.drawText(0, y, text)
else:
x = -self._offset
gap = 50 # space between repeating text blocks
while x < self.width():
painter.drawText(x, y, text)
x += self._text_width + gap
def cleanup(self):
"""Stop all timers to prevent memory leaks."""
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()

View File

@@ -1 +0,0 @@
{'files': ['main_window.py']}

View File

@@ -1,73 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import QSize
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
DOM_XML = """
<ui language='c++'>
<widget class='BECMainWindow' name='bec_main_window'>
</widget>
</ui>
"""
class BECMainWindowPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
# We want to initialize BECMainWindow upon starting designer
t = BECMainWindow(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Containers"
def icon(self):
return designer_material_icon(BECMainWindow.ICON_NAME)
def includeFile(self):
return "bec_main_window"
def initialize(self, form_editor):
import os
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
app = QApplication.instance()
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
self._form_editor = form_editor
def isContainer(self):
return True
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECMainWindow"
def toolTip(self):
return "BECMainWindow"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,48 +1,24 @@
from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
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.container_utils import WidgetContainerUtils
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,
NotificationCentre,
NotificationIndicator,
)
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
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)
class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
RPC = False
PLUGIN = False
def __init__(
self,
@@ -56,55 +32,10 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title)
# Notification Centre overlay
self.notification_centre = NotificationCentre(parent=self) # Notification layer
self.notification_broker = BECNotificationBroker()
self._nc_margin = 16
self._position_notification_centre()
# Init ui
self._init_ui()
self._connect_to_theme_change()
# Connections to BEC Notifications
self.bec_dispatcher.connect_slot(
self.display_client_message, MessageEndpoints.client_info()
)
def setCentralWidget(self, widget: QWidget, qt_default: bool = False): # type: ignore[override]
"""
Reimplement QMainWindow.setCentralWidget so that the *main content*
widget always lives on the lower layer of the stacked layout that
hosts our notification overlays.
Args:
widget: The widget that should become the new central content.
qt_default: When *True* the call is forwarded to the base class so
that Qt behaves exactly as the original implementation (used
during __init__ when we first install ``self._full_content``).
"""
super().setCentralWidget(widget)
self.notification_centre.raise_()
self.statusBar().raise_()
def resizeEvent(self, event):
super().resizeEvent(event)
self._position_notification_centre()
def _position_notification_centre(self):
"""Keep the notification panel at a fixed margin top-right."""
if not hasattr(self, "notification_centre"):
return
margin = getattr(self, "_nc_margin", 16) # px
nc = self.notification_centre
nc.move(self.width() - nc.width() - margin, margin)
################################################################################
# MainWindow Elements Initialization
################################################################################
def _init_ui(self):
# Set the icon
@@ -112,209 +43,40 @@ class BECMainWindow(BECWidget, QMainWindow):
# Set Menu and Status bar
self._setup_menu_bar()
self._init_status_bar_widgets()
# BEC Specific UI
self.display_app_id()
def _init_status_bar_widgets(self):
"""
Prepare the BEC specific widgets in the status bar.
"""
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
# Setup NotificationIndicator to bottom right of the status bar
self._add_notification_indicator()
################################################################################
# Notification indicator and Notification Centre helpers
def _add_notification_indicator(self):
"""
Add the notification indicator to the status bar and hook the signals.
"""
# Add the notification indicator to the status bar
self.notification_indicator = NotificationIndicator(self)
self.status_bar.addPermanentWidget(self.notification_indicator)
# Connect the notification broker to the indicator
self.notification_centre.counts_updated.connect(self.notification_indicator.update_counts)
self.notification_indicator.filter_changed.connect(self.notification_centre.apply_filter)
self.notification_indicator.show_all_requested.connect(self.notification_centre.show_all)
self.notification_indicator.hide_all_requested.connect(self.notification_centre.hide_all)
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
wrapper = QWidget()
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
@@ -402,64 +164,14 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self._app_id_label.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):
"""
Display a client message in the status bar.
Args:
msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty.
"""
message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if expiration and expiration > 0:
self._client_info_expire_timer.start(int(expiration * 1000))
################################################################################
# General and Cleanup Methods
################################################################################
@SafeSlot(str)
def change_theme(self, theme: str):
"""
Change the theme of the application and propagate it to widgets.
Args:
theme(str): Either "light" or "dark".
"""
set_theme(theme) # emits theme_updated and applies palette globally
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
apply_theme(theme)
def cleanup(self):
central_widget = self.centralWidget()
if central_widget is not None:
central_widget.close()
central_widget.deleteLater()
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
@@ -470,40 +182,8 @@ class BECMainWindow(BECWidget, QMainWindow):
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()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup()
class BECMainWindowNoRPC(BECMainWindow):
RPC = False
PLUGIN = False
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = BECMainWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())
class UILaunchWindow(BECMainWindow):
RPC = True

View File

@@ -1,17 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.main_window.bec_main_window_plugin import (
BECMainWindowPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMainWindowPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
@@ -21,8 +20,6 @@ class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = AbortButton(parent)
return t

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
@@ -21,8 +20,6 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResetButton(parent)
return t
@@ -51,7 +48,7 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ResetButton"
def toolTip(self):
return "A button that resets the scan queue."
return "A button that reset the scan queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
@@ -21,8 +20,6 @@ class ResumeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResumeButton(parent)
return t

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
@@ -14,6 +15,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = StopButton(parent)
return t

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
@@ -16,6 +17,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -23,8 +26,6 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionIndicator(parent)
return t
@@ -53,7 +54,7 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
return "PositionIndicator"
def toolTip(self):
return ""
return "PositionIndicator"
def whatsThis(self):
return self.toolTip()

View File

@@ -138,11 +138,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[device]._hints
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
precision = self.dev[device].precision
spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"]
@@ -182,13 +178,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
spinner.setVisible(False)
if readback_val is not None:
text = f"{readback_val:.{precision}f}"
readback.setText(text)
readback.setText(f"{readback_val:.{precision}f}")
position_emit(readback_val)
if setpoint_val is not None:
text = f"{setpoint_val:.{precision}f}"
setpoint.setText(text)
setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[device].limits
limit_update(limits)
@@ -211,13 +205,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}")
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
precision = self.dev[device].precision
if precision is not None:
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))

View File

@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner", "screenshot"]
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)

View File

@@ -1,13 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>
@@ -15,6 +14,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +23,6 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox(parent)
return t
@@ -32,7 +30,7 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox.ICON_NAME)

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
@@ -23,8 +22,6 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox2D(parent)
return t
@@ -32,7 +29,7 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox2D.ICON_NAME)

View File

@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)

View File

@@ -1,13 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine
DOM_XML = """
<ui language='c++'>
@@ -15,6 +14,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +23,6 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerControlLine(parent)
return t
@@ -32,7 +30,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerControlLine.ICON_NAME)
@@ -53,7 +51,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return "PositionerControlLine"
def toolTip(self):
return "A widget that controls a single device."
return "A widget that controls a single positioner in line form."
def whatsThis(self):
return self.toolTip()

View File

@@ -15,7 +15,7 @@ logger = bec_logger.logger
class PositionerGroupBox(QGroupBox):
PLUGIN = True
position_update = Signal(float)
def __init__(self, parent, dev_name):
@@ -45,12 +45,7 @@ class PositionerGroupBox(QGroupBox):
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos
def close(self):
self.widget.close()
@@ -60,7 +55,6 @@ class PositionerGroupBox(QGroupBox):
class PositionerGroup(BECWidget, QWidget):
"""Simple Widget to control a positioner in box form"""
PLUGIN = True
ICON_NAME = "grid_view"
USER_ACCESS = ["set_positioners"]

View File

@@ -1 +1 @@
{'files': ['positioner_group.py']}
{'files': ['positioner_group.py']}

View File

@@ -1,8 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
@@ -15,6 +16,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +25,6 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerGroup(parent)
return t
@@ -32,7 +32,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerGroup.ICON_NAME)
@@ -53,7 +53,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "PositionerGroup"
def toolTip(self):
return "Simple Widget to control a positioner in box form"
return "Container Widget to control positioners in compact form, in a grid"
def whatsThis(self):
return self.toolTip()

View File

@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ###
@SafeSlot(str)
@Slot(str)
def set_device(self, device: str):
"""
Set the device.
@@ -112,11 +112,9 @@ class DeviceInputBase(BECWidget):
WidgetIO.set_value(widget=self, value=device)
self.config.default = device
else:
logger.warning(
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
)
logger.warning(f"Device {device} is not in the filtered selection.")
@SafeSlot()
@Slot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,10 +131,9 @@ class DeviceInputBase(BECWidget):
# 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]
if current_device != "":
self.set_device(current_device)
self.set_device(current_device)
@SafeSlot(list)
@Slot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
@@ -149,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ###
@SafeProperty(
@Property(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
@@ -168,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
@SafeProperty(str)
@Property(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@@ -180,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@SafeProperty(bool)
@Property(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@@ -190,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@@ -203,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@@ -216,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@@ -229,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -242,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@@ -255,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@@ -268,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@@ -281,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -294,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO, 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
@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal (str): signal name.
"""
if self.validate_signal(signal):
if self.validate_signal(signal) is True:
WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal
else:
@@ -108,32 +108,25 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._signals = [self._device]
self._hinted_signals = [self._device]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device_info = device._info.get("signals", {})
def _update(kind: Kind):
return FilterIO.update_with_kind(
widget=self,
kind=kind,
signal_filter=self.signal_filter,
device_info=device_info,
device_name=self._device,
)
return [
signal
for signal, signal_info in device_info.items()
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
@@ -278,21 +271,11 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal(str): Signal to validate.
"""
for entry in self.signals:
if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
if signal in self.signals:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)
def cleanup(self):
"""
Cleanup the widget.
"""
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
super().cleanup()

View File

@@ -1 +1,3 @@
{'files': ['device_combobox.py']}
{
"files": ["device_combobox.py"]
}

View File

@@ -1,19 +1,22 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DOM_XML = """
<ui language='c++'>
<widget class='DeviceComboBox' name='device_combo_box'>
<widget class='DeviceComboBox' name='device_combobox'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceComboBox(parent)
return t
@@ -36,7 +37,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return designer_material_icon(DeviceComboBox.ICON_NAME)
def includeFile(self):
return "device_combo_box"
return "device_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -51,7 +52,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceComboBox"
def toolTip(self):
return ""
return "Device ComboBox Example for BEC Widgets"
def whatsThis(self):
return self.toolTip()

View File

@@ -5,7 +5,6 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
@@ -35,7 +34,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
PLUGIN = True
device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal()
def __init__(
@@ -62,7 +60,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._callback_id = None
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.
@@ -95,31 +92,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -175,28 +147,8 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.device_reset.emit()
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -1 +1,3 @@
{'files': ['device_line_edit.py']}
{
"files": ["device_line_edit.py"]
}

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
@@ -16,6 +17,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -23,8 +26,6 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceLineEdit(parent)
return t
@@ -53,7 +54,7 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceLineEdit"
def toolTip(self):
return ""
return "Device LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
@@ -21,8 +20,6 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalComboBox(parent)
return t
@@ -51,7 +48,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalComboBox"
def toolTip(self):
return ""
return "Signal ComboBox Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,10 +1,8 @@
from __future__ import annotations
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -56,7 +54,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
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.
@@ -93,69 +90,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
Args:
obj_name (str): Object name of the signal.
Returns:
bool: True if the object name was found and set, False otherwise.
"""
for i in range(self.count()):
signal_data = self.itemData(i)
if signal_data and signal_data.get("obj_name") == obj_name:
self.setCurrentIndex(i)
return True
return False
def set_to_first_enabled(self) -> bool:
"""
Set the combobox to the first enabled item.
Returns:
bool: True if an enabled item was found and set, False otherwise.
"""
for i in range(self.count()):
if self.model().item(i).isEnabled():
self.setCurrentIndex(i)
return True
return False
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
@@ -168,11 +102,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
if self.validate_signal(text) is False:
return
self.device_signal_changed.emit(text)
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import (
@@ -23,8 +22,6 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalLineEdit(parent)
return t
@@ -53,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalLineEdit"
def toolTip(self):
return ""
return "Signal LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -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", "<unknown>")) 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_())

View File

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

View File

@@ -45,7 +45,6 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue.
"""
USER_ACCESS = ["remove", "screenshot"]
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -90,7 +89,6 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout
self._init_UI()
@@ -167,11 +165,12 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def populate_scans(self):
@@ -204,40 +203,35 @@ class ScanControl(BECWidget, QWidget):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
self.last_scan_found = False
if not self.toggle.checked:
return
enabled = self.toggle.checked
current_scan = self.comboBox_scan_selection.currentText()
history = (
self.client.connector.xread(
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
)
or []
)
if enabled:
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
for scan in reversed(history):
scan_data = scan.get("data")
if not scan_data:
continue
if scan_data.scan_name != current_scan:
continue
ri = getattr(scan_data, "request_inputs", {}) or {}
args_list = ri.get("arg_bundle", [])
if args_list and self.arg_box:
self.arg_box.set_parameters(args_list)
inputs = ri.get("inputs", {})
kwargs = ri.get("kwargs", {})
merged = {**inputs, **kwargs}
if merged and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(merged)
self.last_scan_found = True
break
for scan in history:
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
if scan_name == current_scan:
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
"parameter"
]["args"]
args_list = []
for key, value in args_dict.items():
args_list.append(key)
args_list.extend(value)
if len(args_list) > 1 and self.arg_box is not None:
self.arg_box.set_parameters(args_list)
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
"kwargs"
]
if kwargs and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(kwargs)
self.last_scan_found = True
break
else:
self.last_scan_found = False
else:
self.last_scan_found = False
@SafeProperty(str)
def current_scan(self):

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
@@ -21,8 +20,6 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ScanControl(parent)
return t
@@ -30,7 +27,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(ScanControl.ICON_NAME)
@@ -51,7 +48,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ScanControl"
def toolTip(self):
return ""
return "ScanControl"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,4 +1,4 @@
from typing import Literal, Sequence
from typing import Literal
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -36,7 +36,7 @@ class ScanArgType:
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS_DICT = "dict" # Used when the type is provided as a dict with Literal key
LITERALS = "dict"
class SettingsDialog(QDialog):
@@ -83,39 +83,6 @@ class ScanSpinBox(QSpinBox):
self.setValue(default)
class ScanLiteralsComboBox(QComboBox):
def __init__(
self, parent=None, arg_name: str | None = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.default = default
if default is not None:
self.setCurrentText(default)
def set_literals(self, literals: Sequence[str | int | float | None]) -> None:
"""
Set the list of literals for the combo box.
Args:
literals: List of literal values (can be strings, integers, floats or None)
"""
self.clear()
literals = set(literals) # Remove duplicates
if None in literals:
literals.remove(None)
self.addItem("")
self.addItems([str(value) for value in literals])
# find index of the default value
index = max(self.findText(str(self.default)), 0)
self.setCurrentIndex(index)
def get_value(self) -> str | None:
return self.currentText() if self.currentText() else None
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
@@ -170,7 +137,7 @@ class ScanGroupBox(QGroupBox):
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
device_selected = Signal(str)
@@ -259,11 +226,7 @@ class ScanGroupBox(QGroupBox):
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
default = item.get("default", None)
item_type = item.get("type", None)
if isinstance(item_type, dict) and "Literal" in item_type:
widget_class = self.WIDGET_HANDLER.get(ScanArgType.LITERALS_DICT, None)
else:
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
if widget_class is None:
logger.error(
f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'"
@@ -276,8 +239,6 @@ class ScanGroupBox(QGroupBox):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget.setToolTip(item["tooltip"])
@@ -375,8 +336,6 @@ class ScanGroupBox(QGroupBox):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit) and device_object:
value = widget.get_current_device().name
elif isinstance(widget, ScanLiteralsComboBox):
value = widget.get_value()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
@@ -21,8 +20,6 @@ class DapComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DapComboBox(parent)
return t

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
@@ -21,8 +20,6 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = LMFitDialog(parent)
return t
@@ -51,7 +48,7 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LMFitDialog"
def toolTip(self):
return "Dialog for displaying the fit summary and params for LMFit DAP processes"
return "LMFitDialog"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,870 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""
import collections
import fcntl
import html
import os
import pty
import re
import signal
import sys
import time
import pyte
from pygments.token import Token
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot as Slot
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
"green": "#00CD00",
"brown": "#996633", # Brown, replacing the yellow
"blue": "#0000EE",
"magenta": "#CD00CD",
"cyan": "#00CDCD",
"white": "#E5E5E5",
"brightblack": "#7F7F7F",
"brightred": "#FF0000",
"brightgreen": "#00FF00",
"brightyellow": "#FFFF00",
"brightblue": "#5C5CFF",
"brightmagenta": "#FF00FF",
"brightcyan": "#00FFFF",
"brightwhite": "#FFFFFF",
}
control_keys_mapping = {
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
}
normal_keys_mapping = {
QtCore.Qt.Key_Return: b"\n",
QtCore.Qt.Key_Space: b" ",
QtCore.Qt.Key_Enter: b"\n",
QtCore.Qt.Key_Tab: b"\t",
QtCore.Qt.Key_Backspace: b"\x08",
QtCore.Qt.Key_Home: b"\x47",
QtCore.Qt.Key_End: b"\x4f",
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0e",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",
QtCore.Qt.Key_F2: b"\x1b\x32",
QtCore.Qt.Key_F3: b"\x1b\x33",
QtCore.Qt.Key_F4: b"\x1b\x34",
QtCore.Qt.Key_F5: b"\x1b\x35",
QtCore.Qt.Key_F6: b"\x1b\x36",
QtCore.Qt.Key_F7: b"\x1b\x37",
QtCore.Qt.Key_F8: b"\x1b\x38",
QtCore.Qt.Key_F9: b"\x1b\x39",
QtCore.Qt.Key_F10: b"\x1b\x30",
QtCore.Qt.Key_F11: b"\x45",
QtCore.Qt.Key_F12: b"\x46",
}
def QtKeyToAscii(event):
"""
Convert the Qt key event to the corresponding ASCII sequence for
the terminal. This works fine for standard alphanumerical characters, but
most other characters require terminal specific control sequences.
The conversion below works for TERM="linux" terminals.
"""
if sys.platform == "darwin":
# special case for MacOS
# /!\ Qt maps ControlModifier to CMD
# CMD-C, CMD-V for copy/paste
# CTRL-C and other modifiers -> key mapping
if event.modifiers() == QtCore.Qt.MetaModifier:
if event.key() == Qt.Key_Backspace:
return control_keys_mapping.get(Qt.Key_W)
return control_keys_mapping.get(event.key())
elif event.modifiers() == QtCore.Qt.ControlModifier:
if event.key() == Qt.Key_C:
# copy
return "copy"
elif event.key() == Qt.Key_V:
# paste
return "paste"
return None
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
if event.modifiers() == QtCore.Qt.ControlModifier:
return control_keys_mapping.get(event.key())
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, cols, rows, historyLength):
super().__init__(cols, rows, historyLength, ratio=1 / rows)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request (for example),
this can be for other requests
"""
try:
os.write(self._fd, data.encode("utf-8"))
except (IOError, OSError):
pass
def resize(self, lines, columns):
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return # No changes.
self.dirty.clear()
self.dirty.update(range(lines))
self.save_cursor()
if lines < self.lines:
if lines <= self.cursor.y:
nlines_to_move_up = self.lines - lines
for i in range(nlines_to_move_up):
line = self.buffer[i] # .pop(0)
self.history.top.append(line)
self.cursor_position(0, 0)
self.delete_lines(nlines_to_move_up)
self.restore_cursor()
self.cursor.y -= nlines_to_move_up
else:
self.restore_cursor()
self.lines, self.columns = lines, columns
self.history = History(
self.history.top,
self.history.bottom,
1 / self.lines,
self.history.size,
self.history.position,
)
self.set_margins()
class Backend(QtCore.QObject):
"""
Poll Bash.
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
file descriptor of the Bash terminal.
"""
# Signals to communicate with ``_TerminalWidget``.
dataReady = pyqtSignal(object)
processExited = pyqtSignal()
def __init__(self, fd, cols, rows):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, cols, rows, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._fd_readable)
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
self.processExited.emit()
self.notifier.setEnabled(False)
return
# Feed output into Pyte's state machine and send the new screen
# output to the GUI
self.stream.feed(out)
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QWidget):
"""Container widget for the terminal text area"""
PLUGIN = True
ICON_NAME = "terminal"
prompt = pyqtSignal(bool)
def __init__(self, parent=None, cols=132):
super().__init__(parent)
self.term = _TerminalWidget(self, cols, rows=43)
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
self.scroll_bar = QScrollBar(Qt.Vertical, self)
# self.scroll_bar.hide()
layout = QHBoxLayout(self)
layout.addWidget(self.term)
layout.addWidget(self.scroll_bar)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
pal = QPalette()
self.set_bgcolor(pal.window().color())
self.set_fgcolor(pal.windowText().color())
self.term.set_scroll_bar(self.scroll_bar)
self.set_cmd("bec --nogui")
self._check_designer_timer = QTimer()
self._check_designer_timer.timeout.connect(self.check_designer)
self._check_designer_timer.start(1000)
def minimumSizeHint(self):
size = self.term.sizeHint()
size.setWidth(size.width() + self.scroll_bar.width())
return size
def sizeHint(self):
return self.minimumSizeHint()
def check_designer(self, calls={"n": 0}):
calls["n"] += 1
if self.term.fd is not None:
# already started
self._check_designer_timer.stop()
elif self.window().windowTitle().endswith("[Preview]"):
# assuming Designer preview -> start
self._check_designer_timer.stop()
self.term.start()
elif calls["n"] >= 3:
# assuming not in Designer -> stop checking
self._check_designer_timer.stop()
def get_rows(self):
return self.term.rows
def set_rows(self, rows):
self.term.rows = rows
self.adjustSize()
self.updateGeometry()
def get_cols(self):
return self.term.cols
def set_cols(self, cols):
self.term.cols = cols
self.adjustSize()
self.updateGeometry()
def get_bgcolor(self):
return QColor.fromString(self.term.bg_color)
def set_bgcolor(self, color):
self.term.bg_color = color.name(QColor.HexRgb)
def get_fgcolor(self):
return QColor.fromString(self.term.fg_color)
def set_fgcolor(self, color):
self.term.fg_color = color.name(QColor.HexRgb)
def get_cmd(self):
return self.term._cmd
def set_cmd(self, cmd):
self.term._cmd = cmd
if self.term.fd is None:
# not started yet
self.term.clear()
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
def start(self, deactivate_ctrl_d=True):
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text, hit_return=False):
"""Push some text to the terminal"""
return self.term.push(text, hit_return=hit_return)
def execute_command(self, command):
self.push(command, hit_return=True)
def set_prompt_tokens(self, *tokens):
"""Prepare regexp to identify prompt, based on tokens
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
"""
regex_parts = []
for token_type, token_value in tokens:
if token_type == Token.PromptNum: # Handle dynamic prompt number
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
else:
# Escape other prompt parts (e.g., "In [", "]: ")
if not token_value:
regex_parts.append(".+?") # arbitrary string
else:
regex_parts.append(re.escape(token_value))
# Combine into a single regex
prompt_pattern = "".join(regex_parts)
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
def terminate(self, timeout=10):
self.term.stop(timeout=timeout)
def send_ctrl_c(self, timeout=None):
self.term.send_ctrl_c(timeout)
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
cmd = pyqtProperty(str, get_cmd, set_cmd)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
prompt = pyqtSignal(bool)
def __init__(self, parent, cols=125, rows=50, **kwargs):
# regexp to match prompt
self._prompt_re = None
# last prompt
self._prompt_str = None
# process pid
self.pid = None
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
# command to execute
self._cmd = ""
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Default colors
pal = QPalette()
self._fg_color = pal.text().color().name()
self._bg_color = pal.base().color().name()
# Specify the terminal size in terms of lines and columns.
self._rows = rows
self._cols = cols
self.output = collections.deque()
super().__init__(parent)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_bar = None
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
self.setCursorWidth(char_width)
self.adjustSize()
self.updateGeometry()
self.update_stylesheet()
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, hexcolor):
self._bg_color = hexcolor
self.update_stylesheet()
@property
def fg_color(self):
return self._fg_color
@fg_color.setter
def fg_color(self, hexcolor):
self._fg_color = hexcolor
self.update_stylesheet()
def update_stylesheet(self):
self.setStyleSheet(
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
)
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, rows: int):
if self.backend is None:
# not initialized yet, ok to change
self._rows = rows
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change rows after console is started.")
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, cols: int):
if self.fd is None:
# not initialized yet, ok to change
self._cols = cols
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change cols after console is started.")
def start(self, deactivate_ctrl_d: bool = False):
self._deactivate_ctrl_d = deactivate_ctrl_d
self.update_term_size()
# Start the Bash process
self.pid, self.fd = self.fork_shell()
if self.fd:
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.cols, self.rows)
self.backend.dataReady.connect(self.data_ready)
self.backend.processExited.connect(self.process_exited)
else:
self.process_exited()
def process_exited(self):
self.fd = None
self.clear()
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
self.setReadOnly(True)
def send_ctrl_c(self, wait_prompt=True, timeout=None):
"""Send CTRL-C to the process
If wait_prompt=True (default), wait for a new prompt after CTRL-C
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
"""
os.kill(self.pid, signal.SIGINT)
if wait_prompt:
timeout_error = False
if timeout:
def set_timeout_error():
nonlocal timeout_error
timeout_error = True
timeout_timer = QTimer()
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
while self._prompt_str is None:
QApplication.instance().process_events()
if timeout_error:
raise TimeoutError(
f"CTRL-C: could not get back to prompt after {timeout} seconds."
)
def _is_running(self):
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
return True
return False
def stop(self, kill=True, timeout=None):
"""Stop the running process
SIGTERM is the default signal for terminating processes.
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
"""
# try to exit gracefully
os.kill(self.pid, signal.SIGTERM)
# wait until process is truly dead
t0 = time.perf_counter()
while self._is_running():
time.sleep(1)
if timeout is not None and time.perf_counter() - t0 > timeout:
# still alive after 'timeout' seconds
if kill:
# send SIGKILL and make a last check in loop
os.kill(self.pid, signal.SIGKILL)
kill = False
else:
# still running after timeout...
raise TimeoutError(
f"Could not terminate process with pid: {self.pid} within timeout"
)
self.process_exited()
def data_ready(self, screen):
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
This method is triggered via a signal from ``Backend``.
"""
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def minimumSizeHint(self):
"""Return minimum size for current cols and rows"""
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
width = char_width * self.cols
height = char_height * self.rows
return QSize(width, height)
def sizeHint(self):
return self.minimumSizeHint()
def set_scroll_bar(self, scroll_bar):
self.scroll_bar = scroll_bar
self.scroll_bar.setMinimum(0)
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": -1}):
if self.backend is None:
return
if old["value"] == -1:
old["value"] = self.scroll_bar.maximum()
if value <= old["value"]:
# scroll up
# value is number of lines from the start
nlines = old["value"] - value
# history ratio gives prev_page == 1 line
for i in range(nlines):
self.backend.screen.prev_page()
else:
# scroll down
nlines = value - old["value"]
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.redraw_screen()
def adjust_scroll_bar(self):
sb = self.scroll_bar
sb.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
sb.setMaximum(tmp if tmp > 0 else 0)
sb.setSliderPosition(tmp if tmp > 0 else 0)
# if tmp > 0:
# # show scrollbar, but delayed - prevent recursion with widget size change
# QTimer.singleShot(0, scrollbar.show)
# else:
# QTimer.singleShot(0, scrollbar.hide)
sb.valueChanged.connect(self.scroll_value_change)
def write(self, data):
try:
os.write(self.fd, data)
except (IOError, OSError):
self.process_exited()
@Slot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
if self.fd is None:
# not started
return
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
and event.modifiers() == QtCore.Qt.ControlModifier
and event.key() == QtCore.Qt.Key_D
):
return None
code = QtKeyToAscii(event)
if code == "copy":
# MacOS only: CMD-C handling
self.copy()
elif code == "paste":
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
self.write(code)
def push(self, text, hit_return=False):
"""
Write 'text' to terminal
"""
self.write(text.encode("utf-8"))
if hit_return:
self.write(b"\n")
def contextMenuEvent(self, event):
if self.fd is None:
return
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
if "opy" in action.text():
# redefine text without shortcut
# since it probably clashes with control codes (like CTRL-C etc)
action.setText("Copy")
continue
if "aste" in action.text():
# redefine text without shortcut
action.setText("Paste")
# paste -> have to insert with self.push
action.triggered.connect(self._push_clipboard)
continue
menu.removeAction(action)
menu.exec_(event.globalPos())
def _push_clipboard(self):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def move_cursor(self):
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
def mouseReleaseEvent(self, event):
if self.fd is None:
return
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
if clipboard.supportsSelection():
self.push(clipboard.text(QClipboard.Selection))
return None
elif event.button() == Qt.LeftButton:
# left button click
textCursor = self.textCursor()
if textCursor.selectedText():
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', move scrollbar to end
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
self.move_cursor()
return None
return super().mouseReleaseEvent(event)
def redraw_screen(self):
"""
Render the screen as formatted text into the widget.
"""
screen = self.backend.screen
# Clear the widget
if screen.dirty:
self.clear()
while len(self.output) < (max(screen.dirty) + 1):
self.output.append("")
while len(self.output) > (max(screen.dirty) + 1):
self.output.pop()
# Prepare the HTML output
for line_no in screen.dirty:
line = text = ""
style = old_style = ""
old_idx = 0
for idx, ch in screen.buffer[line_no].items():
text += " " * (idx - old_idx - 1)
old_idx = idx
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
text = ""
old_style = style
text += ch.data
if style:
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
# do a check at the cursor position:
# it is possible x pos > output line length,
# for example if last escape codes are "cursor forward" past end of text,
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
if line_no == screen.cursor.y:
llen = len(screen.buffer[line_no])
if llen < screen.cursor.x:
line += " " * (screen.cursor.x - llen)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
if self._prompt_re is not None:
text_buf = self.toPlainText()
prompt = self._prompt_re.search(text_buf)
if prompt is None:
if self._prompt_str:
self.prompt.emit(False)
self._prompt_str = None
else:
prompt_str = prompt.string.rstrip()
if prompt_str != self._prompt_str:
self._prompt_str = prompt_str
self.prompt.emit(True)
# did updates, all clean
screen.dirty.clear()
def update_term_size(self):
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
self._cols = int(self.width() / char_width)
self._rows = int(self.height() / char_height)
def resizeEvent(self, event):
self.update_term_size()
if self.fd:
self.backend.screen.resize(self._rows, self._cols)
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def wheelEvent(self, event):
if not self.fd:
return
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.redraw_screen()
def fork_shell(self):
"""
Fork the current process and execute bec in shell.
"""
try:
pid, fd = pty.fork()
except (IOError, OSError):
return False
if pid == 0:
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
os.putenv("COLUMNS", str(self.cols))
os.putenv("LINES", str(self.rows))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if not self._cmd:
self._cmd = os.environ["SHELL"]
cmd = self._cmd
if isinstance(cmd, str):
cmd = cmd.split()
try:
os.execvp(cmd[0], cmd)
except (IOError, OSError):
pass
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
return pid, fd
if __name__ == "__main__":
import os
import sys
from qtpy import QtGui, QtWidgets
# Create the Qt application and console.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole"
mainwin.setWindowTitle(title)
console = BECConsole(mainwin)
mainwin.setCentralWidget(console)
def check_prompt(at_prompt):
if at_prompt:
print("NEW PROMPT")
else:
print("EXECUTING SOMETHING...")
console.set_prompt_tokens(
(Token.OutPromptNum, ""),
(Token.Prompt, ""), # will match arbitrary string,
(Token.Prompt, " ["),
(Token.PromptNum, "3"),
(Token.Prompt, "/"),
(Token.PromptNum, "1"),
(Token.Prompt, "] "),
(Token.Prompt, ""),
)
console.prompt.connect(check_prompt)
console.start()
# Show widget and launch Qt's event loop.
mainwin.show()
sys.exit(app.exec_())

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