1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 10:40:55 +02:00

Compare commits

..

144 Commits

Author SHA1 Message Date
2c2f108fff fix: macro support 2025-10-14 09:07:44 +02:00
68334d6cf5 fix: set tooltip on collapsiblesection 2025-10-14 09:06:23 +02:00
4a8340853a fix(view): apply view_id and view_title attributes to existing ViewBase instances 2025-10-08 17:59:19 +02:00
a8e357d069 build: add markdown dependency 2025-10-08 17:42:41 +02:00
c4d0eeb594 refactor: move splitter weights to view; change developerview to viewbase 2025-10-08 17:01:41 +02:00
d9654c00f7 feat(markdown): implement markdown to HTML conversion with syntax highlighting 2025-10-08 17:01:41 +02:00
8400e75f7c fix(dependencies): add copier and typer to project dependencies 2025-10-08 17:01:41 +02:00
3a3cfd897d fix(ide): add label text to ide actions 2025-10-08 17:01:41 +02:00
5b6bdcd7d4 fix(actions): set default text position to 'under' 2025-10-08 17:01:41 +02:00
14d7699c1b wip 2025-10-08 17:01:41 +02:00
919874ac1d wip 2025-10-08 17:01:41 +02:00
d5746b8e0c wip 2025-10-08 17:01:41 +02:00
32461e8098 wip - cleanup 2025-10-08 17:01:41 +02:00
4623b5dc2e wip - scan control dialog 2025-10-08 17:01:41 +02:00
f2b422014f f - feat: add macros 2025-10-08 17:01:41 +02:00
227d6b3e45 wip dummy test 2025-10-08 17:01:41 +02:00
f2df07e307 fix: disable stylesheet 2025-10-08 17:01:41 +02:00
60de7263b0 fix: forward file events for shared scripts 2025-10-08 17:01:41 +02:00
08042d1a1c wip - embed view in main app 2025-10-08 17:01:41 +02:00
3462388a35 build: add pylsp-bec dependency 2025-10-08 17:01:41 +02:00
2579da5aac wip - feat(developer view): connect play buttons to terminal widget
Note: this is only meant as a placeholder for the procedures
2025-10-08 17:01:41 +02:00
c117e3ede0 fix(monaco tab): fix saving to disk 2025-10-08 17:01:41 +02:00
dd3b15d1aa feat: add support to format on save 2025-10-08 17:01:41 +02:00
86d627b44e fix: update vim icon 2025-10-08 17:01:41 +02:00
b7a8c81459 f - wip 2025-10-08 17:01:41 +02:00
00481a0aed feat(monaco): various minor improvements for the developer view 2025-10-08 17:01:41 +02:00
9def1e2f71 fix(explorer): ensure parent is forwarded correctly 2025-10-08 17:01:41 +02:00
eaecd59d91 feat(web console): add startup cmd as init arg 2025-10-08 17:01:41 +02:00
f85b84e308 feat(explorer): emit signals for opening files 2025-10-08 17:01:41 +02:00
056301d836 f - cleanup 2025-10-08 17:01:41 +02:00
72e811822c wip - add explorer widget 2025-10-08 17:01:41 +02:00
a49834cdef wip developer view example 2025-10-08 17:01:41 +02:00
bc0f952217 wip monaco dock widget 2025-10-08 17:01:41 +02:00
e4b909cca0 fix(signal_label): dispatcher unsubscribed in the cleanup 2025-10-08 16:10:30 +02:00
d35f802d99 fix(client): abort, reset, stop button removed from RPC access 2025-10-08 16:10:30 +02:00
e7ba29569d test(color_utils): cleanup for pyqtgraph 2025-10-08 16:10:30 +02:00
69568cdfd0 test(device_input_base): added qtbot 2025-10-08 16:10:30 +02:00
44943d5d10 test(busy_loader): tests added 2025-10-08 16:10:30 +02:00
c766f4b84a feat(busy_loader): busy loader added to bec widget base class 2025-10-08 16:10:30 +02:00
bc5424df09 refactor(device_manager_view): added labels to main toolbar 2025-10-03 15:38:11 +02:00
1b35b1b36e fix(available_device_resources): top toolbar size fixed 2025-10-03 15:38:11 +02:00
920e7651b5 perf(device_table_view): text wrapper delegate removed since it was not working correctly anyway 2025-10-03 15:38:11 +02:00
9c14289719 fix(device_manager_view): removed custom styling for overlay 2025-10-03 15:38:11 +02:00
040275ac8b refactor(examples): wrong main app removed 2025-10-03 15:38:11 +02:00
20c94697dd feat(main_app): device manager implemented into main app 2025-10-03 15:38:11 +02:00
5e4d2ec0ef feat(actions): actions can be created with label text with beside or under alignment 2025-10-03 13:56:07 +02:00
8294ef2449 fix: mark processEvents for checks 2025-09-30 14:10:50 +02:00
148b387019 refactor: cleanup 2025-09-30 14:10:50 +02:00
028ba6a684 fix: preset classes for config dialog 2025-09-30 14:10:50 +02:00
f9cc01408d fix: tests 2025-09-30 14:10:50 +02:00
fb2d8ca9d3 style: imports 2025-09-30 14:10:50 +02:00
b65da75f1e refactor: redo device tester 2025-09-30 14:10:50 +02:00
0bb693a062 fix: check plugin exists before loading 2025-09-30 14:10:50 +02:00
33c4527da9 feat: allow setting config in redis 2025-09-30 14:10:50 +02:00
f89b330db3 style: typo 2025-09-30 14:10:50 +02:00
ae7f313fad fix: slightly improve theming 2025-09-30 14:10:50 +02:00
5d148babe5 fix: don't use deprecated api for CDockWidget 2025-09-30 14:10:50 +02:00
63a792aed9 feat(device_manager): add device dialog with presets 2025-09-30 14:10:50 +02:00
f9e21153b6 refactor: genericise config form 2025-09-30 14:10:50 +02:00
7bead79a96 fix: device table theming 2025-09-30 14:10:50 +02:00
eee0ca92a7 refactor: available devices add+remove from toolbar 2025-09-30 14:10:50 +02:00
688b1242e3 fix: add all devices to test list 2025-09-30 14:10:50 +02:00
e93b13ca79 feat: connect available devices to doc and yaml views 2025-09-30 14:10:50 +02:00
f293f1661a feat: add/remove functionality for device table
refactor: use list of configs for general interfaces
2025-09-30 14:10:50 +02:00
6a6fe41f8d refactor: util for MimeData 2025-09-30 14:10:50 +02:00
73c46d47a3 feat(dm): apply shared selection signal util to view 2025-09-30 14:10:50 +02:00
c7cd3c60b4 feat: add shared selection signal util 2025-09-30 14:10:50 +02:00
5cfaeb9efd feat: connect config update to available devices 2025-09-30 14:10:50 +02:00
ced2213e4c fix: allow setting state with other conformation of config 2025-09-30 14:10:50 +02:00
77ea92cd1a feat: prepare available devices for dragging config 2025-09-30 14:10:50 +02:00
53a230c719 feat(device_table): prepare table for drop action 2025-09-30 14:10:50 +02:00
66581b60d1 feat: add available devices to manager view 2025-09-30 14:10:50 +02:00
e618c56c11 fix(dm): add constants.py 2025-09-30 14:10:50 +02:00
b26a568b57 feat: add available device resource browser 2025-09-30 14:10:50 +02:00
95a040522f feat: add ListOfExpandableFrames util 2025-09-30 14:10:50 +02:00
499b4d5615 chore: update qtmonaco dependency 2025-09-30 14:10:50 +02:00
b5c6d93cba refactor: refactor device_manager_view 2025-09-30 14:10:50 +02:00
d92259e8c0 feat(dm-view): initial commit for config_view, ophyd_test and dm_widget 2025-09-30 14:10:50 +02:00
c7a0f531d0 fix(colors): accent colors fetching if theme not provided 2025-09-26 10:47:17 -05:00
e89cefed97 test(main_app): test extended 2025-09-26 10:47:17 -05:00
14d7f1fcad feat(main_app):views with examples for enter and exit hook 2025-09-26 10:47:17 -05:00
49b9cbf553 feat(main_app): main app with interactive app switcher 2025-09-26 10:47:17 -05:00
1803d3dd9d test: remove outdated tests
Note: The stylesheet is now set by qthemes, not the widget itself. As a result, the widget-specific stylesheet remains empty.
2025-09-26 10:47:17 -05:00
a823dd243e feat: add SafeConnect 2025-09-26 10:47:17 -05:00
34ed0daa98 fix: process all deletion events before applying a new theme.
Note: this can be dropped once qthemes is updated.
2025-09-26 10:47:17 -05:00
7c9ba024bc refactor: move to qthemes 1.1.2 2025-09-26 10:47:17 -05:00
8fd091ab44 test: apply theme on qapp creation 2025-09-26 10:47:17 -05:00
84b892d7f0 refactor(spinner): improve enum access 2025-09-26 10:47:17 -05:00
97722bdde7 fix(themes): move apply theme from BECWidget class to server init 2025-09-26 10:47:17 -05:00
63c599db76 fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects 2025-09-26 10:47:17 -05:00
1adabb0955 test: fix tests for qtheme v1 2025-09-26 10:47:17 -05:00
b1d2100e05 fix(serializer): remove deprecated serializer 2025-09-26 10:47:17 -05:00
4420793cf3 ci: add artifact upload 2025-09-26 10:47:17 -05:00
d2fede00d2 test: fixes after theme changes 2025-09-26 10:47:17 -05:00
ff4025c209 build: add missing darkdetect dependency 2025-09-26 10:47:17 -05:00
8f5d28a276 fix(compact_popup): import from qtpy instead of pyside6 2025-09-26 10:47:17 -05:00
1a2ec920f6 chore: fix formatter 2025-09-26 10:47:17 -05:00
098f2d4f6f fix: compact popup layout spacing 2025-09-26 10:47:17 -05:00
706490247b fix: remove pyqtgraph styling logic 2025-09-26 10:47:17 -05:00
a0e190e38d fix: tree items due to pushbutton margins 2025-09-26 10:47:17 -05:00
9aae92aa89 fix: device combobox change paint event to stylesheet change 2025-09-26 10:47:17 -05:00
35f3caf2dd fix(toolbar): toolbar menu button fixed 2025-09-26 10:47:17 -05:00
37191aae62 fix:queue abort button fixed 2025-09-26 10:47:17 -05:00
1feeb11ab0 fix(bec_widgets): adapt to bec_qthemes 1.0 2025-09-26 10:47:17 -05:00
ffa22242d0 build(bec_qthemes): version 1.0 dependency 2025-09-26 10:47:17 -05:00
a32751d368 refactor(advanced_dock_area): profile tools moved to separate module 2025-09-26 10:47:17 -05:00
f60939d231 fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault 2025-09-26 10:47:17 -05:00
fc1e514883 feat(advanced_dock_area): ads has default direction 2025-09-26 10:47:17 -05:00
9e2d0742ca refactor(advanced_dock_area): ads changed to separate widget 2025-09-26 10:47:17 -05:00
16073dfd6d fix(bec_widgets): by default the linux display manager is switched to xcb 2025-09-26 10:47:16 -05:00
410fd517c5 feat(advanced_dock_area): added ads based dock area with profiles 2025-09-26 10:47:16 -05:00
a25781d8d7 refactor(bec_main_window): main app theme renamed to View 2025-09-26 10:47:16 -05:00
9488923381 feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-09-26 10:47:16 -05:00
ad85472698 fix(widget_state_manager): state manager can save to already existing settings
wip widget state manager saving loading file logic
2025-09-26 10:47:16 -05:00
77eb21ac52 fix(widget_state_manager): state manager can save all properties recursively 2025-09-26 10:47:16 -05:00
6f43917cc3 refactor(widget_io): ancestor hierarchy methods consolidated 2025-09-26 10:47:16 -05:00
e45d5da032 feat(widget_io): widget hierarchy find_ancestor added 2025-09-26 10:47:16 -05:00
74f27ec2d9 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-09-26 10:47:16 -05:00
296b858cdd refactor(bec_connector): signals renamed 2025-09-26 10:47:16 -05:00
ab8dfd3811 fix(bec_connector): added name established signal for listeners 2025-09-26 10:47:16 -05:00
b6d4d5d749 fix(bec_connector): dedicated remove signal added for listeners 2025-09-26 10:47:16 -05:00
5a6641f0f9 build: PySide6-QtAds dependency added 2025-09-26 10:47:16 -05:00
semantic-release
1d988a4c57 2.39.0
Automatically generated by python-semantic-release
2025-09-24 16:28:40 +00:00
565c0bd1e7 feat(rpc_base): windows can be raised to front from CLI 2025-09-24 11:27:47 -05:00
975404f483 fix(rpc): fix hide/show 2025-09-24 11:27:47 -05:00
semantic-release
165e5e7d84 2.38.4
Automatically generated by python-semantic-release
2025-09-23 15:05:34 +00:00
108ddae6ca fix(image): add support for specifying preview signals through cli 2025-09-23 17:01:00 +02:00
semantic-release
9737acad58 2.38.3
Automatically generated by python-semantic-release
2025-09-23 14:19:21 +00:00
65bc5f5421 fix(ringprogressbar): fix client signature 2025-09-23 16:18:33 +02:00
475ca9f2d8 fix(connector): only flush pending events 2025-09-23 16:18:33 +02:00
bbb5fc6ce1 fix(ringprogressbar): various fixes and improvements 2025-09-23 16:18:33 +02:00
b1b6c5e6a5 test(ringprogressbar): extend e2e test 2025-09-23 16:18:33 +02:00
3e339348dd chore: deprecate 3.10, add 3.13 2025-09-15 13:48:32 +02:00
semantic-release
4f075151d5 2.38.2
Automatically generated by python-semantic-release
2025-09-11 15:01:23 +00:00
0a24ac2c40 fix(waveform):autorange on scan_status 2025-09-11 16:59:35 +02:00
3a2ec9f1b7 test(crosshair): visibility test added with plotbase fixture 2025-09-11 16:59:35 +02:00
4dc4ede1d2 fix(plot_base): crosshair items are excluded from visible curves and from auto_range 2025-09-11 16:59:35 +02:00
556832fd48 fix(waveform): changing curve visibility refresh markers 2025-09-11 16:59:35 +02:00
72b6f74252 fix(crosshair): ignore fetching data and markers from invisible items 2025-09-11 16:59:35 +02:00
b703b37bbd fix(plot_base): visible items injected into plot item 2025-09-11 16:59:35 +02:00
18ef35f22a docs: move to autoapi 2025-09-10 15:05:54 +02:00
fe67a4f325 ci: fix stale issues job permissions; add workflow dispatch option 2025-08-31 09:59:16 +02:00
semantic-release
f1c3d77a45 2.38.1
Automatically generated by python-semantic-release
2025-08-22 10:06:47 +00:00
ad7cdc60dd fix: move thefuzz dependency to prod 2025-08-22 12:06:01 +02:00
123 changed files with 8191 additions and 1415 deletions

View File

@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets

View File

@@ -2,10 +2,14 @@ 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:

View File

@@ -1,289 +0,0 @@
# 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.10
py-version=3.11
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.

View File

@@ -1,6 +1,91 @@
# CHANGELOG
## v2.39.0 (2025-09-24)
### Bug Fixes
- **rpc**: Fix hide/show
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
### Features
- **rpc_base**: Windows can be raised to front from CLI
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
## v2.38.4 (2025-09-23)
### Bug Fixes
- **image**: Add support for specifying preview signals through cli
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
## v2.38.3 (2025-09-23)
### Bug Fixes
- **connector**: Only flush pending events
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
- **ringprogressbar**: Fix client signature
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
- **ringprogressbar**: Various fixes and improvements
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
### Chores
- Deprecate 3.10, add 3.13
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
### Testing
- **ringprogressbar**: Extend e2e test
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
## v2.38.2 (2025-09-11)
### Bug Fixes
- **crosshair**: Ignore fetching data and markers from invisible items
([`72b6f74`](https://github.com/bec-project/bec_widgets/commit/72b6f74252e1f36339945c549049b166cccf3561))
- **plot_base**: Crosshair items are excluded from visible curves and from auto_range
([`4dc4ede`](https://github.com/bec-project/bec_widgets/commit/4dc4ede1d251d081e5bcf3d37fcc784982c9258e))
- **plot_base**: Visible items injected into plot item
([`b703b37`](https://github.com/bec-project/bec_widgets/commit/b703b37bbdbf97182b58ac4c69c1384fa78d0c12))
- **waveform**: Changing curve visibility refresh markers
([`556832f`](https://github.com/bec-project/bec_widgets/commit/556832fd48bcb16b95df8cf91417d7045bbca2a3))
### Continuous Integration
- Fix stale issues job permissions; add workflow dispatch option
([`fe67a4f`](https://github.com/bec-project/bec_widgets/commit/fe67a4f325cbd41f13102e5698d86ed9e90b048e))
### Documentation
- Move to autoapi
([`18ef35f`](https://github.com/bec-project/bec_widgets/commit/18ef35f22a1b7496b13f833e63a4f3875e1497e3))
### Testing
- **crosshair**: Visibility test added with plotbase fixture
([`3a2ec9f`](https://github.com/bec-project/bec_widgets/commit/3a2ec9f1b74c4bb5f239940b874576a877ce45c0))
## v2.38.1 (2025-08-22)
### Bug Fixes
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features

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.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![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)
[![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

@@ -0,0 +1,226 @@
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.examples.developer_view.developer_view import DeveloperView
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class BECMainApp(BECMainWindow):
def __init__(
self,
parent=None,
*args,
anim_duration: int = ANIMATION_DURATION,
show_examples: bool = False,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self._show_examples = bool(show_examples)
# --- Compose central UI (sidebar + stack)
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
self.stack = QStackedWidget(self)
container = QWidget(self)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.sidebar, 0)
layout.addWidget(self.stack, 1)
self.setCentralWidget(container)
# Mapping for view switching
self._view_index: dict[str, int] = {}
self._current_view_id: str | None = None
self.sidebar.view_selected.connect(self._on_view_selected)
self._add_views()
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = AdvancedDockArea(self)
self.device_manager = DeviceManagerWidget(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
self.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
widget=self.device_manager,
mini_text="DM",
)
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
)
if self._show_examples:
self.add_section("Examples", "examples")
waveform_view_popup = WaveformViewPopup(
parent=self, id="waveform_view_popup", title="Waveform Plot"
)
waveform_view_stack = WaveformViewInline(
parent=self, id="waveform_view_stack", title="Waveform Plot"
)
self.add_view(
icon="show_chart",
title="Waveform With Popup",
id="waveform_popup",
widget=waveform_view_popup,
mini_text="Popup",
)
self.add_view(
icon="show_chart",
title="Waveform InLine Stack",
id="waveform_stack",
widget=waveform_view_stack,
mini_text="Stack",
)
self.set_current("dock_area")
self.sidebar.add_dark_mode_item()
# --- Public API ------------------------------------------------------
def add_section(self, title: str, id: str, position: int | None = None):
return self.sidebar.add_section(title, id, position)
def add_separator(self):
return self.sidebar.add_separator()
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
return self.sidebar.add_dark_mode_item(id=id, position=position)
def add_view(
self,
*,
icon: str,
title: str,
id: str,
widget: QWidget,
mini_text: str | None = None,
position: int | None = None,
from_top: bool = True,
toggleable: bool = True,
exclusive: bool = True,
) -> NavigationItem:
"""
Register a view in the stack and create a matching nav item in the sidebar.
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the view/item.
widget(QWidget): The widget to add to the stack.
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
position(int, optional): Position to insert the nav item.
from_top(bool, optional): Whether to count position from the top or bottom.
toggleable(bool, optional): Whether the nav item is toggleable.
exclusive(bool, optional): Whether the nav item is exclusive.
Returns:
NavigationItem: The created navigation item.
"""
item = self.sidebar.add_item(
icon=icon,
title=title,
id=id,
mini_text=mini_text,
position=position,
from_top=from_top,
toggleable=toggleable,
exclusive=exclusive,
)
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
if isinstance(widget, ViewBase):
view_widget = widget
view_widget.view_id = id
view_widget.view_title = title
else:
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
idx = self.stack.addWidget(view_widget)
self._view_index[id] = idx
return item
def set_current(self, id: str) -> None:
if id in self._view_index:
self.sidebar.activate_item(id)
# Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None:
# Determine current view
current_index = self.stack.currentIndex()
current_view = (
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
)
# Ask current view whether we may leave
if current_view is not None and hasattr(current_view, "on_exit"):
may_leave = current_view.on_exit()
if may_leave is False:
# Veto: restore previous highlight without re-emitting selection
if self._current_view_id is not None:
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
return
# Proceed with switch
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
self.stack.setCurrentIndex(idx)
new_view = self.stack.widget(idx)
self._current_view_id = vid
if hasattr(new_view, "on_enter"):
new_view.on_enter()
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="BEC Main Application")
parser.add_argument(
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
)
# Let Qt consume the remaining args
args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args])
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
w.resize(width, height)
w.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
ANIMATION_DURATION = 500 # ms
class RevealAnimator:
"""Animate reveal/hide for a single widget using opacity + max W/H.
This keeps the widget always visible to avoid jitter from setVisible().
Collapsed state: opacity=0, maxW=0, maxH=0.
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
"""
def __init__(
self,
widget: QWidget,
duration: int = ANIMATION_DURATION,
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
initially_revealed: bool = False,
*,
animate_opacity: bool = True,
animate_width: bool = True,
animate_height: bool = True,
):
self.widget = widget
self.animate_opacity = animate_opacity
self.animate_width = animate_width
self.animate_height = animate_height
# Opacity effect
self.fx = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(self.fx)
# Animations
self.opacity_anim = (
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
)
self.width_anim = (
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
)
self.height_anim = (
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
)
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
if anim is not None:
anim.setDuration(duration)
anim.setEasingCurve(easing)
# Initialize to requested state
self.set_immediate(initially_revealed)
def _natural_sizes(self) -> tuple[int, int]:
sh = self.widget.sizeHint()
w = max(sh.width(), 1)
h = max(sh.height(), 1)
return w, h
def set_immediate(self, revealed: bool):
"""
Immediately set the widget to the target revealed/collapsed state.
Args:
revealed(bool): True to reveal, False to collapse.
"""
w, h = self._natural_sizes()
if self.animate_opacity:
self.fx.setOpacity(1.0 if revealed else 0.0)
if self.animate_width:
self.widget.setMaximumWidth(w if revealed else 0)
if self.animate_height:
self.widget.setMaximumHeight(h if revealed else 0)
def setup(self, reveal: bool):
"""
Prepare animations to transition to the target revealed/collapsed state.
Args:
reveal(bool): True to reveal, False to collapse.
"""
# Prepare animations from current state to target
target_w, target_h = self._natural_sizes()
if self.opacity_anim is not None:
self.opacity_anim.setStartValue(self.fx.opacity())
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
if self.width_anim is not None:
self.width_anim.setStartValue(self.widget.maximumWidth())
self.width_anim.setEndValue(target_w if reveal else 0)
if self.height_anim is not None:
self.height_anim.setStartValue(self.widget.maximumHeight())
self.height_anim.setEndValue(target_h if reveal else 0)
def add_to_group(self, group: QParallelAnimationGroup):
"""
Add the prepared animations to the given animation group.
Args:
group(QParallelAnimationGroup): The animation group to add to.
"""
if self.opacity_anim is not None:
group.addAnimation(self.opacity_anim)
if self.width_anim is not None:
group.addAnimation(self.width_anim)
if self.height_anim is not None:
group.addAnimation(self.height_anim)
def animations(self):
"""
Get a list of all animations (non-None) for adding to a group.
"""
return [
anim
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
if anim is not None
]

View File

@@ -0,0 +1,357 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtWidgets
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
from qtpy.QtWidgets import (
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QScrollArea,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar_components import (
DarkModeNavItem,
NavigationItem,
SectionHeader,
SideBarSeparator,
)
class SideBar(QScrollArea):
view_selected = Signal(str)
toggled = Signal(bool)
def __init__(
self,
parent=None,
title: str = "Control Panel",
collapsed_width: int = 56,
expanded_width: int = 250,
anim_duration: int = ANIMATION_DURATION,
):
super().__init__(parent=parent)
self.setObjectName("SideBar")
# private attributes
self._is_expanded = False
self._collapsed_width = collapsed_width
self._expanded_width = expanded_width
self._anim_duration = anim_duration
# containers
self.components = {}
self._item_opts: dict[str, dict] = {}
# Scroll area properties
self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setFrameShape(QtWidgets.QFrame.NoFrame)
self.setFixedWidth(self._collapsed_width)
# Content widget holding buttons for switching views
self.content = QWidget(self)
self.content_layout = QVBoxLayout(self.content)
self.content_layout.setContentsMargins(0, 0, 0, 0)
self.content_layout.setSpacing(4)
self.setWidget(self.content)
# Track active navigation item
self._active_id = None
# Top row with title and toggle button
self.toggle_row = QWidget(self)
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
self.title_label = QLabel(title, self)
self.title_label.setObjectName("TopTitle")
self.title_label.setStyleSheet("font-weight: 600;")
self.title_fx = QGraphicsOpacityEffect(self.title_label)
self.title_label.setGraphicsEffect(self.title_fx)
self.title_fx.setOpacity(0.0)
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
self.toggle = QToolButton(self)
self.toggle.setCheckable(False)
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
self.toggle.clicked.connect(self.on_expand)
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
# To push the content up always
self._bottom_spacer = QtWidgets.QSpacerItem(
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
)
# Add core widgets to layout
self.content_layout.addWidget(self.toggle_row)
self.content_layout.addItem(self._bottom_spacer)
# Animations
self.width_anim = QPropertyAnimation(self, b"bar_width")
self.width_anim.setDuration(self._anim_duration)
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
self.title_anim.setDuration(self._anim_duration)
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
self.group = QParallelAnimationGroup(self)
self.group.addAnimation(self.width_anim)
self.group.addAnimation(self.title_anim)
self.group.finished.connect(self._on_anim_finished)
app = QtWidgets.QApplication.instance()
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
app.theme.theme_changed.connect(self._on_theme_changed)
@SafeProperty(int)
def bar_width(self) -> int:
"""
Get the current width of the side bar.
Returns:
int: The current width of the side bar.
"""
return self.width()
@bar_width.setter
def bar_width(self, width: int):
"""
Set the width of the side bar.
Args:
width(int): The new width of the side bar.
"""
self.setFixedWidth(width)
@SafeProperty(bool)
def is_expanded(self) -> bool:
"""
Check if the side bar is expanded.
Returns:
bool: True if the side bar is expanded, False otherwise.
"""
return self._is_expanded
@SafeSlot()
@SafeSlot(bool)
def on_expand(self):
"""
Toggle the expansion state of the side bar.
"""
self._is_expanded = not self._is_expanded
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
convert_to_pixmap=False,
)
)
if self._is_expanded:
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
self.group.stop()
# Setting limits for animations of the side bar
self.width_anim.setStartValue(self.width())
self.width_anim.setEndValue(
self._expanded_width if self._is_expanded else self._collapsed_width
)
self.title_anim.setStartValue(self.title_fx.opacity())
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
# Setting limits for animations of the components
for comp in self.components.values():
if hasattr(comp, "setup_animations"):
comp.setup_animations(self._is_expanded)
self.group.start()
if self._is_expanded:
# TODO do not like this trick, but it is what it is for now
self.title_label.setVisible(self._is_expanded)
for comp in self.components.values():
if hasattr(comp, "set_visible"):
comp.set_visible(self._is_expanded)
self.toggled.emit(self._is_expanded)
@SafeSlot()
def _on_anim_finished(self):
if not self._is_expanded:
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
# TODO do not like this trick, but it is what it is for now
self.title_label.setVisible(self._is_expanded)
for comp in self.components.values():
if hasattr(comp, "set_visible"):
comp.set_visible(self._is_expanded)
@SafeSlot(str)
def _on_theme_changed(self, theme_name: str):
# Refresh toggle arrow icon so it picks up the new theme
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
convert_to_pixmap=False,
)
)
# Refresh each component that supports it
for comp in self.components.values():
if hasattr(comp, "refresh_theme"):
comp.refresh_theme()
else:
comp.style().unpolish(comp)
comp.style().polish(comp)
comp.update()
self.style().unpolish(self)
self.style().polish(self)
self.update()
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
"""
Add a section header to the side bar.
Args:
title(str): The title of the section.
id(str): Unique ID for the section.
position(int, optional): Position to insert the section header.
Returns:
SectionHeader: The created section header.
"""
header = SectionHeader(self, title, anim_duration=self._anim_duration)
position = position if position is not None else self.content_layout.count() - 1
self.content_layout.insertWidget(position, header)
for anim in header.animations:
self.group.addAnimation(anim)
self.components[id] = header
return header
def add_separator(
self, *, from_top: bool = True, position: int | None = None
) -> SideBarSeparator:
"""
Add a separator line to the side bar. Separators are treated like regular
items; you can place multiple separators anywhere using `from_top` and `position`.
"""
line = SideBarSeparator(self)
line.setStyleSheet("margin:12px;")
self._insert_nav_item(line, from_top=from_top, position=position)
return line
def add_item(
self,
icon: str,
title: str,
id: str,
mini_text: str | None = None,
position: int | None = None,
*,
from_top: bool = True,
toggleable: bool = True,
exclusive: bool = True,
) -> NavigationItem:
"""
Add a navigation item to the side bar.
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the nav item.
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
position(int, optional): Position to insert the nav item.
from_top(bool, optional): Whether to count position from the top or bottom.
toggleable(bool, optional): Whether the nav item is toggleable.
exclusive(bool, optional): Whether the nav item is exclusive.
Returns:
NavigationItem: The created navigation item.
"""
item = NavigationItem(
parent=self,
title=title,
icon_name=icon,
mini_text=mini_text,
toggleable=toggleable,
exclusive=exclusive,
anim_duration=self._anim_duration,
)
self._insert_nav_item(item, from_top=from_top, position=position)
for anim in item.build_animations():
self.group.addAnimation(anim)
self.components[id] = item
# Connect activation to activation logic, passing id unchanged
item.activated.connect(lambda id=id: self.activate_item(id))
return item
def activate_item(self, target_id: str, *, emit_signal: bool = True):
target = self.components.get(target_id)
if target is None:
return
# Non-toggleable acts like an action: do not change any toggled states
if hasattr(target, "toggleable") and not target.toggleable:
self._active_id = target_id
if emit_signal:
self.view_selected.emit(target_id)
return
is_exclusive = getattr(target, "exclusive", True)
if is_exclusive:
# Radio-like behavior among exclusive items only
for comp_id, comp in self.components.items():
if not isinstance(comp, NavigationItem):
continue
if comp is target:
comp.set_active(True)
else:
# Only untoggle other items that are also exclusive
if getattr(comp, "exclusive", True):
comp.set_active(False)
# Leave non-exclusive items as they are
else:
# Non-exclusive toggles independently
target.set_active(not target.is_active())
self._active_id = target_id
if emit_signal:
self.view_selected.emit(target_id)
def add_dark_mode_item(
self, id: str = "dark_mode", position: int | None = None
) -> DarkModeNavItem:
"""
Add a dark mode toggle item to the side bar.
Args:
id(str): Unique ID for the dark mode item.
position(int, optional): Position to insert the dark mode item.
Returns:
DarkModeNavItem: The created dark mode navigation item.
"""
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
# compute bottom insertion point (same semantics as from_top=False)
self._insert_nav_item(item, from_top=False, position=position)
for anim in item.build_animations():
self.group.addAnimation(anim)
self.components[id] = item
item.activated.connect(lambda id=id: self.activate_item(id))
return item
def _insert_nav_item(
self, item: QWidget, *, from_top: bool = True, position: int | None = None
):
if from_top:
base_index = self.content_layout.indexOf(self._bottom_spacer)
pos = base_index if position is None else min(base_index, position)
else:
base = self.content_layout.indexOf(self._bottom_spacer) + 1
pos = base if position is None else base + max(0, position)
self.content_layout.insertWidget(pos, item)

View File

@@ -0,0 +1,372 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtCore
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty
from bec_widgets.applications.navigation_centre.reveal_animator import (
ANIMATION_DURATION,
RevealAnimator,
)
def get_on_primary():
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
return app.theme.color("ON_PRIMARY")
return "#FFFFFF"
def get_fg():
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
return app.theme.color("FG")
return "#FFFFFF"
class SideBarSeparator(QFrame):
"""A horizontal line separator for use in SideBar."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("SideBarSeparator")
self.setFrameShape(QFrame.NoFrame)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(2)
self.setProperty("variant", "separator")
class SectionHeader(QWidget):
"""A section header with a label and a horizontal line below."""
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
super().__init__(parent)
self.setObjectName("SectionHeader")
self.lbl = QLabel(text, self)
self.lbl.setObjectName("SectionHeaderLabel")
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
self.line = SideBarSeparator(self)
lay = QVBoxLayout(self)
# keep your margins/spacing preferences here if needed
lay.setContentsMargins(12, 0, 12, 0)
lay.setSpacing(6)
lay.addWidget(self.lbl)
lay.addWidget(self.line)
self.animations = self.build_animations()
def build_animations(self) -> list[QPropertyAnimation]:
"""
Build and return animations for expanding/collapsing the sidebar.
Returns:
list[QPropertyAnimation]: List of animations.
"""
return self._reveal.animations()
def setup_animations(self, expanded: bool):
"""
Setup animations for expanding/collapsing the sidebar.
Args:
expanded(bool): True if the sidebar is expanded, False if collapsed.
"""
self._reveal.setup(expanded)
class NavigationItem(QWidget):
"""A nav tile with an icon + labels and an optional expandable body.
Provides animations for collapsed/expanded sidebar states via
build_animations()/setup_animations(), similar to SectionHeader.
"""
activated = QtCore.Signal()
def __init__(
self,
parent=None,
*,
title: str,
icon_name: str,
mini_text: str | None = None,
toggleable: bool = True,
exclusive: bool = True,
anim_duration: int = ANIMATION_DURATION,
):
super().__init__(parent=parent)
self.setObjectName("NavigationItem")
# Private attributes
self._title = title
self._icon_name = icon_name
self._mini_text = mini_text or title
self._toggleable = toggleable
self._toggled = False
self._exclusive = exclusive
# Main Icon
self.icon_btn = QToolButton(self)
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
self.icon_btn.setAutoRaise(True)
self._icon_size_collapsed = QtCore.QSize(20, 20)
self._icon_size_expanded = QtCore.QSize(26, 26)
self.icon_btn.setIconSize(self._icon_size_collapsed)
# Remove QToolButton hover/pressed background/outline
self.icon_btn.setStyleSheet(
"""
QToolButton:hover { background: transparent; border: none; }
QToolButton:pressed { background: transparent; border: none; }
"""
)
# Mini label below icon
self.mini_lbl = QLabel(self._mini_text, self)
self.mini_lbl.setObjectName("NavMiniLabel")
self.mini_lbl.setAlignment(Qt.AlignCenter)
self.mini_lbl.setStyleSheet("font-size: 10px;")
self.reveal_mini_lbl = RevealAnimator(
widget=self.mini_lbl,
initially_revealed=True,
animate_width=False,
duration=anim_duration,
)
# Container for icon + mini label
self.mini_icon = QWidget(self)
mini_lay = QVBoxLayout(self.mini_icon)
mini_lay.setContentsMargins(0, 2, 0, 2)
mini_lay.setSpacing(2)
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
# Title label
self.title_lbl = QLabel(self._title, self)
self.title_lbl.setObjectName("NavTitleLabel")
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.title_lbl.setStyleSheet("font-size: 13px;")
self.reveal_title_lbl = RevealAnimator(
widget=self.title_lbl,
initially_revealed=False,
animate_height=False,
duration=anim_duration,
)
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
lay = QHBoxLayout(self)
lay.setContentsMargins(12, 2, 12, 2)
lay.setSpacing(6)
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
self.icon_size_anim.setDuration(anim_duration)
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
# Connect icon button to emit activation
self.icon_btn.clicked.connect(self._emit_activated)
self.setMouseTracking(True)
self.setAttribute(Qt.WA_StyledBackground, True)
def is_active(self) -> bool:
"""Return whether the item is currently active/selected."""
return self.property("toggled") is True
def build_animations(self) -> list[QPropertyAnimation]:
"""
Build and return animations for expanding/collapsing the sidebar.
Returns:
list[QPropertyAnimation]: List of animations.
"""
return (
self.reveal_title_lbl.animations()
+ self.reveal_mini_lbl.animations()
+ [self.icon_size_anim]
)
def setup_animations(self, expanded: bool):
"""
Setup animations for expanding/collapsing the sidebar.
Args:
expanded(bool): True if the sidebar is expanded, False if collapsed.
"""
self.reveal_mini_lbl.setup(not expanded)
self.reveal_title_lbl.setup(expanded)
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
self.icon_size_anim.setEndValue(
self._icon_size_expanded if expanded else self._icon_size_collapsed
)
def set_visible(self, visible: bool):
"""Set visibility of the title label."""
self.title_lbl.setVisible(visible)
def _emit_activated(self):
self.activated.emit()
def set_active(self, active: bool):
"""
Set the active/selected state of the item.
Args:
active(bool): True to set active, False to deactivate.
"""
self.setProperty("toggled", active)
self.toggled = active
# ensure style refresh
self.style().unpolish(self)
self.style().polish(self)
self.update()
def mousePressEvent(self, event):
self.activated.emit()
super().mousePressEvent(event)
@SafeProperty(bool)
def toggleable(self) -> bool:
"""
Whether the item is toggleable (like a button) or not (like an action).
Returns:
bool: True if toggleable, False otherwise.
"""
return self._toggleable
@toggleable.setter
def toggleable(self, value: bool):
"""
Set whether the item is toggleable (like a button) or not (like an action).
Args:
value(bool): True to make toggleable, False otherwise.
"""
self._toggleable = bool(value)
@SafeProperty(bool)
def toggled(self) -> bool:
"""
Whether the item is currently toggled/selected.
Returns:
bool: True if toggled, False otherwise.
"""
return self._toggled
@toggled.setter
def toggled(self, value: bool):
"""
Set whether the item is currently toggled/selected.
Args:
value(bool): True to set toggled, False to untoggle.
"""
self._toggled = value
if value:
new_icon = material_icon(
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
)
else:
new_icon = material_icon(
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
)
self.icon_btn.setIcon(new_icon)
# Re-polish so QSS applies correct colors to icon/labels
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
@SafeProperty(bool)
def exclusive(self) -> bool:
"""
Whether the item is exclusive in its toggle group.
Returns:
bool: True if exclusive, False otherwise.
"""
return self._exclusive
@exclusive.setter
def exclusive(self, value: bool):
"""
Set whether the item is exclusive in its toggle group.
Args:
value(bool): True to make exclusive, False otherwise.
"""
self._exclusive = bool(value)
def refresh_theme(self):
# Recompute icon/label colors according to current theme and state
# Trigger the toggled setter to rebuild the icon with the correct color
self.toggled = self._toggled
# Ensure QSS-driven text/icon colors refresh
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
class DarkModeNavItem(NavigationItem):
"""Bottom action item that toggles app theme and updates its icon/text."""
def __init__(
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
):
super().__init__(
parent=parent,
title="Dark mode",
icon_name="dark_mode",
mini_text="Dark",
toggleable=False, # action-like, no selection highlight changes
exclusive=False,
anim_duration=anim_duration,
)
self._id = id
self._sync_from_qapp_theme()
self.activated.connect(self.toggle_theme)
def _qapp_dark_enabled(self) -> bool:
qapp = QApplication.instance()
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
def _sync_from_qapp_theme(self):
is_dark = self._qapp_dark_enabled()
# Update labels
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
self.mini_lbl.setText("Light" if is_dark else "Dark")
# Update icon
self.icon_btn.setIcon(
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
)
def refresh_theme(self):
self._sync_from_qapp_theme()
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
def toggle_theme(self):
"""Toggle application theme and update icon/text."""
from bec_widgets.utils.colors import apply_theme
is_dark = self._qapp_dark_enabled()
apply_theme("light" if is_dark else "dark")
self._sync_from_qapp_theme()

View File

@@ -0,0 +1,564 @@
from __future__ import annotations
import os
from functools import partial
from typing import List
import PySide6QtAds as QtAds
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QThreadPool, QTimer
from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
AvailableDeviceResources,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
PresetClassDeviceConfigDialog,
)
logger = bec_logger.logger
_yes_no_question = partial(
QMessageBox.question,
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
defaultButton=QMessageBox.StandardButton.No,
)
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, client=None, *args, **kwargs)
self._config_helper = config_helper.ConfigHelper(self.client.connector)
self._shared_selection = SharedSelectionSignal()
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Available Resources Widget
self.available_devices = AvailableDeviceResources(
self, shared_selection_signal=self._shared_selection
)
self.available_devices_dock = QtAds.CDockWidget(
self.dock_manager, "Available Devices", self
)
self.available_devices_dock.setWidget(self.available_devices)
# Device Table View widget
self.device_table_view = DeviceTableView(
self, shared_selection_signal=self._shared_selection
)
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Config View widget
self.dm_config_view = DMConfigView(self)
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Docstring View
self.dm_docs_view = DocstringView(self)
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
# Ophyd Test view
self.ophyd_test_view = DMOphydTest(self)
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
# Arrange widgets within the QtAds dock manager
# Central widget area
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea,
self.dm_docs_view_dock,
self.central_dock_area,
)
# Left Area
self.left_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area
)
# Right area
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view
)
for dock in self.dock_manager.dockWidgets():
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
# Fetch all dock areas of the dock widgets (on our case always one dock area)
for dock in self.dock_manager.dockWidgets():
area = dock.dockAreaWidget()
area.titleBar().setVisible(False)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [3, 1])
# self.set_default_view([2, 8, 2], [2, 2, 4])
# Connect slots
for signal, slots in [
(
self.device_table_view.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.available_devices.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.ophyd_test_view.device_validated,
(self.device_table_view.update_device_validation,),
),
(
self.device_table_view.device_configs_changed,
(
self.ophyd_test_view.change_device_configs,
self.available_devices.mark_devices_used,
),
),
(
self.available_devices.add_selected_devices,
(self.device_table_view.add_device_configs,),
),
(
self.available_devices.del_selected_devices,
(self.device_table_view.remove_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
self._add_toolbar()
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
# Add IO actions
self._add_io_actions()
self._add_table_actions()
self.toolbar.show_bundles(["IO", "Table"])
self._root_layout.insertWidget(0, self.toolbar)
def _add_io_actions(self):
# Create IO bundle
io_bundle = ToolbarBundle("IO", self.toolbar.components)
load = MaterialIconAction(
icon_name="file_open",
parent=self,
tooltip="Load configuration file from disk",
label_text="Load Config",
)
self.toolbar.components.add_safe("load", load)
load.action.triggered.connect(self._load_file_action)
io_bundle.add_action("load")
# Add safe to disk
safe_to_disk = MaterialIconAction(
icon_name="file_save",
parent=self,
tooltip="Save config to disk",
label_text="Save Config",
)
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
safe_to_disk.action.triggered.connect(self._save_to_disk_action)
io_bundle.add_action("safe_to_disk")
# Add load config from redis
load_redis = MaterialIconAction(
icon_name="cached",
parent=self,
tooltip="Load current config from Redis",
label_text="Reload Config",
)
load_redis.action.triggered.connect(self._load_redis_action)
self.toolbar.components.add_safe("load_redis", load_redis)
io_bundle.add_action("load_redis")
# Update config action
update_config_redis = MaterialIconAction(
icon_name="cloud_upload",
parent=self,
tooltip="Update current config in Redis",
label_text="Update Config",
)
update_config_redis.action.triggered.connect(self._update_redis_action)
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
io_bundle.add_action("update_config_redis")
# Add load config from plugin dir
self.toolbar.add_bundle(io_bundle)
# Table actions
def _add_table_actions(self) -> None:
table_bundle = ToolbarBundle("Table", self.toolbar.components)
# Reset composed view
reset_composed = MaterialIconAction(
icon_name="delete_sweep",
parent=self,
tooltip="Reset current composed config view",
label_text="Reset Config",
)
reset_composed.action.triggered.connect(self._reset_composed_view)
self.toolbar.components.add_safe("reset_composed", reset_composed)
table_bundle.add_action("reset_composed")
# Add device
add_device = MaterialIconAction(
icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device"
)
add_device.action.triggered.connect(self._add_device_action)
self.toolbar.components.add_safe("add_device", add_device)
table_bundle.add_action("add_device")
# Remove device
remove_device = MaterialIconAction(
icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device"
)
remove_device.action.triggered.connect(self._remove_device_action)
self.toolbar.components.add_safe("remove_device", remove_device)
table_bundle.add_action("remove_device")
# Rerun validation
rerun_validation = MaterialIconAction(
icon_name="checklist",
parent=self,
tooltip="Run device validation with 'connect' on selected devices",
label_text="Rerun Validation",
)
rerun_validation.action.triggered.connect(self._rerun_validation_action)
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
table_bundle.add_action("rerun_validation")
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
# Most likly, no actions on available devices
# Actions (vielleicht bundle fuer available devices )
# - reset composed view
# - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice)
# - remove device
# - rerun validation (with/without connect)
# IO actions
def _coming_soon(self):
return QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
# Check if plugin repo is installed...
try:
plugin_path = plugin_repo_path()
plugin_name = plugin_package_name()
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
except ValueError:
# Get the recovery config path as fallback
config_path = self._get_recovery_config_path()
logger.warning(
f"No plugin repository installed, fallback to recovery config path: {config_path}"
)
# Implement the file loading logic here
start_dir = os.path.abspath(config_path)
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
try:
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self.device_table_view.set_device_config(
config
) # TODO ADD QDialog with 'replace', 'add' & 'cancel'
# TODO would we ever like to add the current config to an existing composition
@SafeSlot()
def _load_redis_action(self):
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
reply = _yes_no_question(
self,
"Load currently active config",
"Do you really want to discard the current config and reload?",
)
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
self.device_table_view.set_device_config(
self.client.device_manager._get_redis_device_config()
)
else:
return
@SafeSlot()
def _update_redis_action(self):
"""Action to push the current composition to Redis"""
reply = _yes_no_question(
self,
"Push composition to Redis",
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
)
if reply != QMessageBox.StandardButton.Yes:
return
if self.device_table_view.table.contains_invalid_devices():
return QMessageBox.warning(
self, "Validation has errors!", "Please resolve before proceeding."
)
if self.ophyd_test_view.validation_running():
return QMessageBox.warning(
self, "Validation has not completed.", "Please wait for the validation to finish."
)
self._push_compositiion_to_redis()
def _push_compositiion_to_redis(self):
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
threadpool.start(comm)
@SafeSlot()
def _save_to_disk_action(self):
"""Action for the 'safe_to_disk' action to save the current config to disk."""
# Check if plugin repo is installed...
try:
config_path = self._get_recovery_config_path()
except ValueError:
# Get the recovery config path as fallback
config_path = os.path.abspath(os.path.expanduser("~"))
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
# Implement the file loading logic here
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=config_path
)
if file_path:
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
with open(file_path, "w") as file:
file.write(yaml.dump(config))
# Table actions
@SafeSlot()
def _reset_composed_view(self):
"""Action for the 'reset_composed_view' action to reset the composed view."""
reply = _yes_no_question(
self,
"Clear View",
"You are about to clear the current composed config view, please confirm...",
)
if reply == QMessageBox.StandardButton.Yes:
self.device_table_view.clear_device_configs()
# TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device
# For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc..
# For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required..
@SafeSlot()
def _add_device_action(self):
"""Action for the 'add_device' action to add a new device."""
# Implement the logic to add a new device
dialog = PresetClassDeviceConfigDialog(parent=self)
dialog.accepted_data.connect(self._add_to_table_from_dialog)
dialog.open()
@SafeSlot(dict)
def _add_to_table_from_dialog(self, data):
self.device_table_view.add_device_configs([data])
@SafeSlot()
def _remove_device_action(self):
"""Action for the 'remove_device' action to remove a device."""
self.device_table_view.remove_selected_rows()
# TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations
# in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'!
@SafeSlot()
def _rerun_validation_action(self):
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
configs = self.device_table_view.table.selected_configs()
self.ophyd_test_view.change_device_configs(configs, True, True)
####### Default view has to be done with setting up splitters ########
def set_default_view(
self, horizontal_weights: list, vertical_weights: list
): # TODO separate logic for all ads based widgets
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(
self, *, horizontal=None, vertical=None
): # TODO separate logic for all ads based widgets
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
def _get_recovery_config_path(self) -> str:
"""Get the recovery config path from the log_writer config."""
# pylint: disable=protected-access
log_writer_config = self.client._service_config.config.get("log_writer", {})
writer = DeviceConfigWriter(service_config=log_writer_config)
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
if __name__ == "__main__":
import sys
from copy import deepcopy
from bec_lib.bec_yaml_loader import yaml_load
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
w = QWidget()
l = QVBoxLayout()
w.setLayout(l)
apply_theme("dark")
button = DarkModeButton()
l.addWidget(button)
device_manager_view = DeviceManagerView()
l.addWidget(device_manager_view)
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
# cfg = yaml_load(config_path)
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
# # config = device_manager_view.client.device_manager._get_redis_device_config()
# device_manager_view.device_table_view.set_device_config(cfg)
w.show()
w.setWindowTitle("Device Manager View")
w.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,119 @@
"""Top Level wrapper for device_manager widget"""
from __future__ import annotations
import os
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
self.setLayout(self.stacked_layout)
# Add device manager view
self.device_manager_view = DeviceManagerView()
self.stacked_layout.addWidget(self.device_manager_view)
# Add overlay widget
self._overlay_widget = QtWidgets.QWidget(self)
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_overlay(self):
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
# Load current config
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
# Load config from disk
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
self.button_load_config_from_file.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_config_from_file)
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
self._overlay_widget.setVisible(True)
def _load_config_from_file_clicked(self):
"""Handle click on 'Load Config From File' button."""
start_dir = os.path.expanduser("~")
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
self._load_config_from_file(file_path)
def _load_config_from_file(self, file_path: str):
try:
config = yaml_load(file_path)
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
config_list = []
for name, cfg in config.items():
config_list.append(cfg)
config_list[-1]["name"] = name
self.device_manager_view.device_table_view.set_device_config(config_list)
# self.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
@SafeSlot()
def _load_config_clicked(self):
"""Handle click on 'Load Current Config' button."""
config = self.client.device_manager._get_redis_device_config()
self.device_manager_view.device_table_view.set_device_config(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
from bec_widgets.utils.colors import apply_theme
apply_theme("light")
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
device_manager = DeviceManagerWidget()
# config = device_manager.client.device_manager._get_redis_device_config()
# device_manager.device_table_view.set_device_config(config)
layout.addWidget(device_manager)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
dark_mode_button = DarkModeButton()
layout.addWidget(dark_mode_button)
widget.show()
device_manager.setWindowTitle("Device Manager View")
device_manager.resize(1600, 1200)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,363 @@
from __future__ import annotations
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.waveform import Waveform
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class ViewBase(QWidget):
"""Wrapper for a content widget used inside the main app's stacked view.
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
Args:
content (QWidget): The actual view widget to display.
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
self.view_id = id
self.view_title = title
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.content.setParent(None)
self.content = content
self.layout().addWidget(content)
@SafeSlot()
def on_enter(self) -> None:
"""Called after the view becomes current/visible.
Default implementation does nothing. Override in subclasses.
"""
pass
@SafeSlot()
def on_exit(self) -> bool:
"""Called before the view is switched away/hidden.
Return True to allow switching, or False to veto.
Default implementation allows switching.
"""
return True
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
####################################################################################################
# Example views for demonstration/testing purposes
####################################################################################################
# --- Popup UI version ---
class WaveformViewPopup(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.waveform = Waveform(parent=self)
self.set_content(self.waveform)
@SafeSlot()
def on_enter(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("Configure Waveform View")
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
# same as in the CurveRow used in waveform
self.device_edit = DeviceComboBox(parent=self)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(label)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
v = QVBoxLayout(dialog)
v.addLayout(form)
v.addWidget(buttons)
if dialog.exec_() == QDialog.Accepted:
self.waveform.plot(
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
)
@SafeSlot()
def on_exit(self) -> bool:
ans = QMessageBox.question(
self,
"Switch and clear?",
"Do you want to switch views and clear the plot?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans == QMessageBox.Yes:
self.waveform.clear_all()
return True
return False
# --- Inline stacked UI version ---
class WaveformViewInline(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# Root layout for this view uses a stacked layout
self.stack = QStackedLayout()
container = QWidget(self)
container.setLayout(self.stack)
self.set_content(container)
# --- Page 0: Settings page (inline form)
self.settings_page = QWidget()
sp_layout = QVBoxLayout(self.settings_page)
sp_layout.setContentsMargins(16, 16, 16, 16)
sp_layout.setSpacing(12)
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
self.device_edit = DeviceComboBox(parent=self.settings_page)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self.settings_page)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(title)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
btn_row = QHBoxLayout()
ok_btn = QPushButton("OK", parent=self.settings_page)
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
btn_row.addStretch(1)
btn_row.addWidget(cancel_btn)
btn_row.addWidget(ok_btn)
sp_layout.addLayout(form)
sp_layout.addLayout(btn_row)
# --- Page 1: Waveform page
self.waveform_page = QWidget()
wf_layout = QVBoxLayout(self.waveform_page)
wf_layout.setContentsMargins(0, 0, 0, 0)
self.waveform = Waveform(parent=self.waveform_page)
wf_layout.addWidget(self.waveform)
# --- Page 2: Exit confirmation page (inline)
self.confirm_page = QWidget()
cp_layout = QVBoxLayout(self.confirm_page)
cp_layout.setContentsMargins(16, 16, 16, 16)
cp_layout.setSpacing(12)
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
cp_buttons = QHBoxLayout()
no_btn = QPushButton("No", parent=self.confirm_page)
yes_btn = QPushButton("Yes", parent=self.confirm_page)
cp_buttons.addStretch(1)
cp_buttons.addWidget(no_btn)
cp_buttons.addWidget(yes_btn)
cp_layout.addWidget(qlabel)
cp_layout.addLayout(cp_buttons)
# Add pages to the stack
self.stack.addWidget(self.settings_page) # index 0
self.stack.addWidget(self.waveform_page) # index 1
self.stack.addWidget(self.confirm_page) # index 2
# Wire settings buttons
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
cancel_btn.clicked.connect(self._show_waveform_without_changes)
# Prepare result holder for the inline confirmation
self._exit_choice_yes = None
yes_btn.clicked.connect(lambda: self._exit_reply(True))
no_btn.clicked.connect(lambda: self._exit_reply(False))
@SafeSlot()
def on_enter(self) -> None:
# Always start on the settings page when entering
self.stack.setCurrentIndex(0)
@SafeSlot()
def on_exit(self) -> bool:
# Show inline confirmation page and synchronously wait for a choice
# -> trick to make the choice blocking, however popup would be cleaner solution
self._exit_choice_yes = None
self.stack.setCurrentIndex(2)
loop = QEventLoop()
self._exit_loop = loop
loop.exec_()
if self._exit_choice_yes:
self.waveform.clear_all()
return True
# Revert to waveform view if user cancelled switching
self.stack.setCurrentIndex(1)
return False
def _apply_settings_and_show_waveform(self):
dev = self.device_edit.currentText()
sig = self.entry_edit.currentText()
if dev and sig:
self.waveform.plot(y_name=dev, y_entry=sig)
self.stack.setCurrentIndex(1)
def _show_waveform_without_changes(self):
# Just show waveform page without plotting
self.stack.setCurrentIndex(1)
def _exit_reply(self, yes: bool):
self._exit_choice_yes = bool(yes)
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
self._exit_loop.quit()

View File

@@ -27,7 +27,6 @@ class _WidgetsEnumType(str, enum.Enum):
_Widgets = {
"AbortButton": "AbortButton",
"BECDockArea": "BECDockArea",
"BECMainWindow": "BECMainWindow",
"BECProgressBar": "BECProgressBar",
@@ -50,7 +49,6 @@ _Widgets = {
"PositionerBox2D": "PositionerBox2D",
"PositionerControlLine": "PositionerControlLine",
"PositionerGroup": "PositionerGroup",
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"SBBMonitor": "SBBMonitor",
@@ -60,7 +58,6 @@ _Widgets = {
"SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
@@ -97,28 +94,6 @@ except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class AbortButton(RPCBase):
"""A button that abort the scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class AdvancedDockArea(RPCBase):
@rpc_call
def new(
@@ -236,6 +211,26 @@ class AutoUpdates(RPCBase):
"""
class AvailableDeviceResources(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class BECDock(RPCBase):
@property
@rpc_call
@@ -1100,6 +1095,48 @@ class Curve(RPCBase):
"""
class DMConfigView(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DMOphydTest(RPCBase):
"""Widget to test device configurations using ophyd devices."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DapComboBox(RPCBase):
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
@@ -2414,7 +2451,7 @@ class Image(RPCBase):
Set the image source and update the image.
Args:
monitor(str): The name of the monitor to use for the image.
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
color_map(str): The color map to use for the image.
color_bar(str): The type of color bar to use. Options are "simple" or "full".
@@ -3990,28 +4027,6 @@ class RectangularROI(RPCBase):
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class ResumeButton(RPCBase):
"""A button that continue scan queue."""
@@ -4198,7 +4213,7 @@ class RingProgressBar(RPCBase):
"""
@rpc_call
def set_precision(self, precision: "int", bar_index: "int" = None):
def set_precision(self, precision: "int", bar_index: "int | None" = None):
"""
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
@@ -4987,28 +5002,6 @@ class SignalLineEdit(RPCBase):
"""
class StopButton(RPCBase):
"""A button that stops the current scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format"""

View File

@@ -285,6 +285,18 @@ class BECGuiClient(RPCBase):
"""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,
@@ -443,8 +455,8 @@ class BECGuiClient(RPCBase):
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -454,11 +466,24 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
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()
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()
def _update_dynamic_namespace(self, server_registry: dict):
"""

View File

@@ -202,6 +202,11 @@ 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,
@@ -225,6 +230,12 @@ 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

@@ -1,98 +0,0 @@
from typing import List
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class AutoHideOverlay(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# IMPORTANT, if you decide to use autohide, you must set these flags before creating ANY CDockManager, it has to be put into inity, let me know and I can enforce it
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
CDockManager.setAutoHideConfigFlag(
CDockManager.DockAreaHasAutoHideButton, False
) # to not have everywhere these buttons
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.auto_hide_controls = QPushButton("Auto Hide Controls", self.left_widget)
self.auto_hide_controls.setCheckable(True)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.auto_hide_controls)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
# Connect signals
self.auto_hide_controls.toggled.connect(self.toggle_auto_hide)
def toggle_auto_hide(self, checked: bool):
# start as collapsed, implement better logic
# self.autohide_container.collapseView(checked)
# or this if you just want toggle
self.autohide_container.toggleCollapseState()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHideOverlay()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
sys.exit(app.exec())

View File

@@ -1,123 +0,0 @@
from typing import Optional
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class AutoHidePush(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# CDockManager.setConfigFlag(CDockManager.FocusHighlighting, True)
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
# CDockManager.setAutoHideConfigFlag(CDockManager.AutoHideShowOnMouseOver, True)
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.push_mode_btn = QPushButton(self.left_widget)
self.push_mode_btn.setCheckable(True)
self.push_mode_btn.setText("Pin (show and push)")
self.push_mode_btn.setChecked(False)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.push_mode_btn)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
self._last_side = QtAds.SideBarRight
# Ensure auto-hide starts collapsed and button text is correct
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
# Connect signals
self.push_mode_btn.toggled.connect(self.toggle_pin_mode)
def toggle_pin_mode(self, pinned: bool):
# pinned=True -> convert auto-hide overlay into a normal dock (push layout) and show it
if pinned:
if self.autohide_container is not None:
# Remember the current side (edge) to restore when unpinning
if hasattr(self.autohide_container, "sideBarLocation"):
self._last_side = self.autohide_container.sideBarLocation()
# Move contents back into the dock container (this deletes the auto-hide container)
self.autohide_container.moveContentsToParent()
self.autohide_container = None
# Ensure the dock is visible when pinned
self.list_dock.show()
self.push_mode_btn.setText("Unpin (send to sidebar)")
else:
# Convert the pinned dock back into an auto-hide overlay at the last used side and collapse it
side = getattr(self, "_last_side", QtAds.SideBarRight)
container = self.dock_manager.addAutoHideDockWidget(side, self.list_dock)
# Preserve a sensible size from the current dock widget geometry
if side in (QtAds.SideBarLeft, QtAds.SideBarRight):
target_size = max(200, min(self.list_dock.width(), 600))
else:
target_size = max(200, min(self.list_dock.height(), 600))
container.setSize(int(target_size))
self.autohide_container = container
# Collapse so it disappears to the side tab
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHidePush()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
sys.exit(app.exec())

View File

@@ -0,0 +1,63 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget
class DeveloperView(ViewBase):
"""
A view for users to write scripts and macros and execute them within the application.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.developer_widget = DeveloperWidget(parent=self)
self.set_content(self.developer_widget)
# Apply stretch after the layout is done
self.set_default_view([2, 5, 3], [7, 3])
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.applications.main_app import BECMainApp
app = QApplication(sys.argv)
apply_theme("dark")
_app = BECMainApp()
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
_app.resize(width, height)
developer_view = DeveloperView()
_app.add_view(
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
)
_app.show()
# developer_view.show()
# developer_view.setWindowTitle("Developer View")
# developer_view.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,347 @@
import re
import markdown
import PySide6QtAds as QtAds
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
def markdown_to_html(md_text: str) -> str:
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
# Preprocess: convert consecutive >>> lines to Python code blocks
def replace_python_examples(match):
indent = match.group(1)
examples = match.group(2)
# Remove >>> prefix and clean up the code
lines = []
for line in examples.strip().split("\n"):
line = line.strip()
if line.startswith(">>> "):
lines.append(line[4:]) # Remove '>>> '
elif line.startswith(">>>"):
lines.append(line[3:]) # Remove '>>>'
code = "\n".join(lines)
return f"{indent}```python\n{indent}{code}\n{indent}```"
# Match one or more consecutive >>> lines (with same indentation)
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
html = markdown.markdown(
md_text,
extensions=extensions,
extension_configs={
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
},
output_format="html",
)
# Remove hardcoded background colors that conflict with themes
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
html = re.sub(r"background: #[^;]*;", "", html)
# Add CSS to force code blocks to wrap
css = """
<style>
pre, code {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.codehilite pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
</style>
"""
return css + html
class DeveloperWidget(BECWidget, QWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.toolbar = ModularToolBar(self)
self.init_developer_toolbar()
self._root_layout.addWidget(self.toolbar)
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.explorer = IDEExplorer(self)
self.console = WebConsole(self)
self.terminal = WebConsole(self, startup_cmd="")
self.monaco = MonacoDock(self)
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
self.signature_help = QTextEdit(self)
self.signature_help.setAcceptRichText(True)
self.signature_help.setReadOnly(True)
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
opt = self.signature_help.document().defaultTextOption()
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
self.signature_help.document().setDefaultTextOption(opt)
self.monaco.signature_help.connect(
lambda text: self.signature_help.setHtml(markdown_to_html(text))
)
# Create the dock widgets
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
self.explorer_dock.setWidget(self.explorer)
self.console_dock = QtAds.CDockWidget("Console", self)
self.console_dock.setWidget(self.console)
self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self)
self.monaco_dock.setWidget(self.monaco)
self.terminal_dock = QtAds.CDockWidget("Terminal", self)
self.terminal_dock.setWidget(self.terminal)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.monaco_dock)
# Add the dock widgets to the dock manager
area_bottom = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock
)
self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom)
area_left = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock
)
area_left.titleBar().setVisible(False)
for dock in self.dock_manager.dockWidgets():
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
self.signature_dock = QtAds.CDockWidget("Signature Help", self)
self.signature_dock.setWidget(self.signature_help)
area_right = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock
)
self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right)
# Connect editor signals
self.explorer.file_open_requested.connect(self._open_new_file)
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
self.toolbar.show_bundles(["save", "execution", "settings"])
def init_developer_toolbar(self):
"""Initialize the developer toolbar with necessary actions and widgets."""
save_button = MaterialIconAction(
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
)
save_button.action.triggered.connect(self.on_save)
self.toolbar.components.add_safe("save", save_button)
save_as_button = MaterialIconAction(
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
)
self.toolbar.components.add_safe("save_as", save_as_button)
save_bundle = ToolbarBundle("save", self.toolbar.components)
save_bundle.add_action("save")
save_bundle.add_action("save_as")
self.toolbar.add_bundle(save_bundle)
run_action = MaterialIconAction(
icon_name="play_arrow",
tooltip="Run current file",
label_text="Run",
filled=True,
parent=self,
)
run_action.action.triggered.connect(self.on_execute)
self.toolbar.components.add_safe("run", run_action)
stop_action = MaterialIconAction(
icon_name="stop",
tooltip="Stop current execution",
label_text="Stop",
filled=True,
parent=self,
)
stop_action.action.triggered.connect(self.on_stop)
self.toolbar.components.add_safe("stop", stop_action)
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
execution_bundle.add_action("run")
execution_bundle.add_action("stop")
self.toolbar.add_bundle(execution_bundle)
vim_action = MaterialIconAction(
icon_name="vim",
tooltip="Toggle Vim Mode",
label_text="Vim",
filled=True,
parent=self,
checkable=True,
)
self.toolbar.components.add_safe("vim", vim_action)
vim_action.action.triggered.connect(self.on_vim_triggered)
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
settings_bundle.add_action("vim")
self.toolbar.add_bundle(settings_bundle)
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
save_shortcut.activated.connect(self.on_save)
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
save_as_shortcut.activated.connect(self.on_save_as)
def _open_new_file(self, file_name: str, scope: str):
self.monaco.open_file(file_name, scope)
# Set read-only mode for shared files
if "shared" in scope:
self.monaco.set_file_readonly(file_name, True)
# Add appropriate icon based on file type
if "script" in scope:
# Use script icon for script files
icon = material_icon("script", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
elif "macro" in scope:
# Use function icon for macro files
icon = material_icon("function", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
@SafeSlot()
def on_save(self):
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
@SafeSlot(bool)
def _on_save_enabled_update(self, enabled: bool):
self.toolbar.components.get_action("save").action.setEnabled(enabled)
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
@SafeSlot()
def on_execute(self):
self.script_editor_tab = self.monaco.last_focused_editor
if not self.script_editor_tab:
return
self.current_script_id = upload_script(
self.client.connector, self.script_editor_tab.widget().get_text()
)
self.console.write(f'bec._run_script("{self.current_script_id}")')
print(f"Uploaded script with ID: {self.current_script_id}")
@SafeSlot()
def on_stop(self):
print("Stopping execution...")
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value):
if not isinstance(value, str):
raise ValueError("Script ID must be a string.")
self._current_script_id = value
self._update_subscription()
def _update_subscription(self):
if self.current_script_id:
self.bec_dispatcher.connect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
else:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
print(f"Script execution info: {content}")
current_lines = content.get("current_lines")
if not current_lines:
self.script_editor_tab.widget().clear_highlighted_lines()
return
line_number = current_lines[0]
self.script_editor_tab.widget().clear_highlighted_lines()
self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number)
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.applications.main_app import BECMainApp
app = QApplication(sys.argv)
apply_theme("dark")
_app = BECMainApp()
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
_app.resize(width, height)
developer_view = DeveloperView()
_app.add_view(
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
)
_app.show()
# developer_view.show()
# developer_view.setWindowTitle("Developer View")
# developer_view.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -219,7 +219,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.processEvents()
QApplication.sendPostedEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:

View File

@@ -36,6 +36,8 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
start_busy: bool = False,
busy_text: str = "Loading…",
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
**kwargs,
):
@@ -65,6 +67,20 @@ class BECWidget(BECConnector):
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
# Initialize optional busy loader overlay utility (lazy by default)
self._busy_overlay = None
self._loading = False
if start_busy and isinstance(self, QWidget):
try:
overlay = self._ensure_busy_overlay(busy_text=busy_text)
if overlay is not None:
overlay.setGeometry(self.rect())
overlay.raise_()
overlay.show()
self._loading = True
except Exception as exc:
logger.debug(f"Busy loader init skipped: {exc}")
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -81,8 +97,77 @@ class BECWidget(BECConnector):
theme = qapp.theme.theme
else:
theme = "dark"
self._update_overlay_theme(theme)
self.apply_theme(theme)
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
"""Create the busy overlay on demand and cache it in _busy_overlay.
Returns the overlay instance or None if not a QWidget.
"""
if not isinstance(self, QWidget):
return None
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
from bec_widgets.utils.busy_loader import install_busy_loader
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
self._busy_overlay = overlay
return overlay
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
"""Create and attach the loading overlay to this widget if QWidget is present."""
if not isinstance(self, QWidget):
return
self._ensure_busy_overlay(busy_text=busy_text)
if start_busy and self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
def set_busy(self, enabled: bool, text: str | None = None) -> None:
"""
Enable/disable the loading overlay. Optionally update the text.
Args:
enabled(bool): Whether to enable the loading overlay.
text(str, optional): The text to display on the overlay. If None, the text is not changed.
"""
if not isinstance(self, QWidget):
return
if getattr(self, "_busy_overlay", None) is None:
self._ensure_busy_overlay(busy_text=text or "Loading…")
if text is not None:
self.set_busy_text(text)
if enabled:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
else:
self._busy_overlay.hide()
self._loading = bool(enabled)
def is_busy(self) -> bool:
"""
Check if the loading overlay is enabled.
Returns:
bool: True if the loading overlay is enabled, False otherwise.
"""
return bool(getattr(self, "_loading", False))
def set_busy_text(self, text: str) -> None:
"""
Update the text on the loading overlay.
Args:
text(str): The text to display on the overlay.
"""
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
overlay = self._ensure_busy_overlay(busy_text=text)
if overlay is not None:
overlay.set_text(text)
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
@@ -92,6 +177,14 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
def _update_overlay_theme(self, theme: str):
try:
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and hasattr(overlay, "update_palette"):
overlay.update_palette()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
@@ -150,6 +243,22 @@ class BECWidget(BECConnector):
child.close()
child.deleteLater()
# Tear down busy overlay explicitly to stop spinner and remove filters
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and shiboken6.isValid(overlay):
try:
overlay.hide()
filt = getattr(overlay, "_filter", None)
if filt is not None and shiboken6.isValid(filt):
try:
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
self._busy_overlay = None
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:

View File

@@ -0,0 +1,253 @@
from __future__ import annotations
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
class _OverlayEventFilter(QObject):
"""Keeps the overlay sized and stacked over its target widget."""
def __init__(self, target: QWidget, overlay: QWidget):
super().__init__(target)
self._target = target
self._overlay = overlay
def eventFilter(self, obj, event):
if obj is self._target and event.type() in (
QEvent.Resize,
QEvent.Show,
QEvent.LayoutRequest,
QEvent.Move,
):
self._overlay.setGeometry(self._target.rect())
self._overlay.raise_()
return False
class BusyLoaderOverlay(QWidget):
"""
A semi-transparent scrim with centered text and an animated spinner.
Call show()/hide() directly, or use via `install_busy_loader(...)`.
Args:
parent(QWidget): The parent widget to overlay.
text(str): Initial text to display.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAutoFillBackground(False)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._opacity = opacity
self._label = QLabel(text, self)
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(self._label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
self._label.setFont(f)
self._spinner = SpinnerWidget(self)
self._spinner.setFixedSize(42, 42)
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
lay.addWidget(self._label, 0, Qt.AlignHCenter)
lay.addStretch(1)
self._frame = QFrame(self)
self._frame.setObjectName("busyFrame")
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self._frame.lower()
# Defaults
self._scrim_color = QColor(0, 0, 0, 110)
self._label_color = QColor(240, 240, 240)
self.update_palette()
# Start hidden; interactions beneath are blocked while visible
self.hide()
# --- API ---
def set_text(self, text: str):
"""
Update the overlay text.
Args:
text(str): The text to display on the overlay.
"""
self._label.setText(text)
def set_opacity(self, opacity: float):
"""
Set overlay opacity (0..1).
Args:
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
"""
self._opacity = max(0.0, min(1.0, float(opacity)))
# Re-apply alpha using the current theme color
if isinstance(self._scrim_color, QColor):
base = QColor(self._scrim_color)
base.setAlpha(int(255 * self._opacity))
self._scrim_color = base
self.update()
def update_palette(self):
"""
Update colors from the current application theme.
"""
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme # type: ignore[attr-defined]
self._bg = theme.color("BORDER")
self._fg = theme.color("FG")
self._primary = theme.color("PRIMARY")
else:
# Fallback neutrals
self._bg = QColor(30, 30, 30)
self._fg = QColor(230, 230, 230)
# Semi-transparent scrim derived from bg
self._scrim_color = QColor(self._bg)
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self._spinner.update()
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
self._label.setStyleSheet(f"color: {fg_hex};")
self._frame.setStyleSheet(
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
)
self.update()
# --- QWidget overrides ---
def showEvent(self, e):
self._spinner.start()
super().showEvent(e)
def hideEvent(self, e):
self._spinner.stop()
super().hideEvent(e)
def resizeEvent(self, e):
super().resizeEvent(e)
r = self.rect().adjusted(10, 10, -10, -10)
self._frame.setGeometry(r)
def paintEvent(self, e):
super().paintEvent(e)
def install_busy_loader(
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
) -> BusyLoaderOverlay:
"""
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
Args:
target(QWidget): The widget to overlay.
text(str): Initial text to display.
start_loading(bool): If True, show the overlay immediately.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
overlay.setGeometry(target.rect())
filt = _OverlayEventFilter(target, overlay)
overlay._filter = filt # type: ignore[attr-defined]
target.installEventFilter(filt)
if start_loading:
overlay.show()
return overlay
# --------------------------
# Launchable demo
# --------------------------
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget()
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
btn_off.clicked.connect(lambda: right.set_busy(False))
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
apply_theme("light")
w = DemoWindow()
w.show()
sys.exit(app.exec())

View File

@@ -1,19 +1,17 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Literal
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_qthemes import apply_theme as apply_theme_global
from bec_qthemes._theme import AccentColors
from pydantic_core import PydanticCustomError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
@@ -29,13 +27,14 @@ def get_theme_palette():
return palette
def get_accent_colors() -> AccentColors | None:
def get_accent_colors() -> AccentColors:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return None
accent_colors = AccentColors()
return accent_colors
return QApplication.instance().theme.accent_colors

View File

@@ -209,8 +209,11 @@ 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]]
else:
# Handle all curves
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
self.items = self.plot_item.items
# Create or update markers

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
@@ -31,10 +32,11 @@ class ExpandableGroupFrame(QFrame):
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self._title_text = f"<b>{title}</b>"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(5, 0, 0, 0)
self.setLayout(self._layout)
self._create_title_layout(title, icon)
@@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame):
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._internal_title_layout = QHBoxLayout()
self._title_layout.addLayout(self._internal_title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title = ClickableLabel()
self._set_title_text(self._title_text)
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self._internal_title_layout.addWidget(self._title_icon)
self._internal_title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._internal_title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def get_title_layout(self) -> QHBoxLayout:
return self._internal_title_layout
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame):
else:
self._title_icon.setVisible(False)
@SafeProperty(str)
def title_text(self): # type: ignore
return self._title_text
@title_text.setter
def title_text(self, title_text: str):
self._title_text = title_text
self._set_title_text(self._title_text)
def _set_title_text(self, title_text: str):
self._title.setText(title_text)
# Application example
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from types import NoneType
from types import GenericAlias, NoneType, UnionType
from typing import NamedTuple
from bec_lib.logger import bec_logger
@@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
@@ -216,6 +216,9 @@ class PydanticModelForm(TypedForm):
self._connect_to_theme_change()
@SafeSlot()
def clear(self): ...
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
@@ -280,3 +283,24 @@ class PydanticModelForm(TypedForm):
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False
class PydanticModelFormItem(DynamicFormItem):
def __init__(
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
) -> None:
self._data_model = model
super().__init__(parent=parent, spec=spec)
self._main_widget.form_data_updated.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = PydanticModelForm(data_model=self._data_model)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.get_form_data()
def setValue(self, value: dict):
self._main_widget.set_data(self._data_model.model_validate(value))

View File

@@ -1,10 +1,23 @@
from __future__ import annotations
import inspect
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
from typing import (
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
OrderedDict,
Protocol,
TypeVar,
get_args,
runtime_checkable,
)
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -158,9 +171,10 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
# Sadly, QWidget and ABC are not compatible
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
@@ -175,6 +189,7 @@ class DynamicFormItem(QWidget):
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@@ -350,11 +365,13 @@ class DictFormItem(DynamicFormItem):
self._main_widget.replace_data(value)
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
_IW = TypeVar("_IW", bound=int | float | str)
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
item: type[_IW]
widget: type[QWidget]
default: int | float | str
default: _IW
class ListFormItem(DynamicFormItem):
@@ -380,7 +397,7 @@ class ListFormItem(DynamicFormItem):
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
@@ -430,10 +447,17 @@ class ListFormItem(DynamicFormItem):
self._add_list_item(val)
self._repop(self._data)
def _item_height(self):
return int(QFontMetrics(self.font()).height() * 1.5)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
item_widget.setMinimumHeight(self._item_height())
self._main_widget.setGridSize(QSize(0, self._item_height()))
if (layout := item_widget.layout()) is not None:
layout.setContentsMargins(0, 0, 0, 0)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
@@ -470,14 +494,11 @@ class ListFormItem(DynamicFormItem):
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
@@ -545,7 +566,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
@runtime_checkable
class _ItemTypeFn(Protocol):
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
WidgetTypeRegistry = OrderedDict[
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
]
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
@@ -586,7 +614,10 @@ def widget_from_type(
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
return widget_type
return widget_type(spec)
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)

View File

@@ -0,0 +1,133 @@
import re
from functools import partial
from re import Pattern
from typing import Generic, Iterable, NamedTuple, TypeVar
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import (
SORT_KEY_ROLE,
SortableQListWidgetItem,
)
logger = bec_logger.logger
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
def __init__(
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
) -> None:
super().__init__(parent)
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
self.item_tuple = _Items
self._item_class = item_class
self._item_dict: dict[str, _Items] = {}
def __contains__(self, id: str):
return id in self._item_dict
def clear(self) -> None:
self._item_dict = {}
return super().clear()
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
Args:
id (str): the key under which to store the list item in the internal dict
Returns:
The widget created in the addition process
"""
def _remove_item(item: QListWidgetItem):
self.takeItem(self.row(item))
del self._item_dict[id]
self.sortItems()
def _updatesize(item: QListWidgetItem, item_widget: _EF):
item_widget.adjustSize()
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
item = SortableQListWidgetItem(self)
item.setData(SORT_KEY_ROLE, id) # used for sorting
item_widget = self._item_class(*args, **kwargs)
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
item_widget.imminent_deletion.connect(partial(_remove_item, item))
item_widget.broadcast_size_hint.connect(item.setSizeHint)
self.addItem(item)
self.setItemWidget(item, item_widget)
self._item_dict[id] = self.item_tuple(item, item_widget)
item.setSizeHint(item_widget.sizeHint())
return (item, item_widget)
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
items = [self.takeItem(0) for i in range(self.count())]
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
for it in items:
self.addItem(it)
# reattach its custom widget
widget = self.itemWidget(it)
if widget:
self.setItemWidget(it, widget)
def item_widget_pairs(self):
return self._item_dict.values()
def widgets(self):
return (i.widget for i in self._item_dict.values())
def get_item_widget(self, id: str):
if (item := self._item_dict.get(id)) is None:
return None
return item
def set_hidden_pattern(self, pattern: Pattern):
self.hide_all()
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
def set_hidden(self, ids: Iterable[str]):
self._set_hidden(ids, True)
def _set_hidden(self, ids: Iterable[str], hidden: bool):
for id in ids:
if (_item := self._item_dict.get(id)) is not None:
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
else:
logger.warning(
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
)
self.sortItems()
def hide_all(self):
self.set_hidden_state_on_all(True)
def unhide_all(self):
self.set_hidden_state_on_all(False)
def set_hidden_state_on_all(self, hidden: bool):
for _item in self._item_dict.values():
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
self.sortItems()
@SafeSlot(str)
def update_filter(self, value: str):
if value == "":
return self.unhide_all()
try:
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
except Exception:
self.unhide_all()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -229,6 +230,8 @@ 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

@@ -33,6 +33,26 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar):
"""
Helper function to create a toolbar button with text beside or under the icon.
Args:
toolbar_action(ToolBarAction): The toolbar action to create the button for.
toolbar(ModularToolBar): The toolbar to add the button to.
"""
btn = QToolButton(parent=toolbar)
btn.setDefaultAction(toolbar_action.action)
btn.setAutoRaise(True)
if toolbar_action.text_position == "beside":
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
else:
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
btn.setText(toolbar_action.label_text)
toolbar.addWidget(btn)
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
@@ -114,15 +134,39 @@ class SeparatorAction(ToolBarAction):
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
def __init__(
self,
standard_icon,
tooltip=None,
checkable=False,
label_text: str | None = None,
text_position: Literal["beside", "under"] | None = None,
parent=None,
):
"""
Action with a standard Qt icon for the toolbar.
Args:
standard_icon: The standard icon from QStyle.
tooltip(str, optional): The tooltip for the action. Defaults to None.
checkable(bool, optional): Whether the action is checkable. Defaults to False.
label_text(str | None, optional): Optional label text to display beside or under the icon.
text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon.
parent(QWidget or None, optional): Parent widget for the underlying QAction.
"""
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
self.label_text = label_text
self.text_position = text_position
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
if self.label_text is not None:
create_action_with_text(toolbar_action=self, toolbar=toolbar)
else:
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
@@ -139,6 +183,8 @@ class MaterialIconAction(ToolBarAction):
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
label_text (str | None, optional): Optional label text to display beside or under the icon.
text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
@@ -149,12 +195,20 @@ class MaterialIconAction(ToolBarAction):
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
label_text: str | None = None,
text_position: Literal["beside", "under"] | None = None,
parent=None,
):
"""
MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon.
This enables per-action icon text without breaking the existing API.
"""
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.filled = filled
self.color = color
self.label_text = label_text
self.text_position = text_position
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
@@ -178,7 +232,10 @@ class MaterialIconAction(ToolBarAction):
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
if self.label_text is not None:
create_action_with_text(toolbar_action=self, toolbar=toolbar)
else:
toolbar.addAction(self.action)
def get_icon(self):
"""

View File

@@ -25,6 +25,7 @@ from shiboken6 import isValid
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
@@ -901,6 +902,7 @@ if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
apply_theme("dark")
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = AdvancedDockArea(mode="developer", root_widget=True)

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,14 @@ class CollapsibleSection(QWidget):
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
def __init__(
self,
parent=None,
title="",
indentation=10,
show_add_button=False,
tooltip: str | None = None,
):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
@@ -50,6 +57,8 @@ class CollapsibleSection(QWidget):
self.header_button.mouseMoveEvent = self._header_mouse_move_event
self.header_button.dragEnterEvent = self._header_drag_enter_event
self.header_button.dropEvent = self._header_drop_event
if tooltip:
self.header_button.setToolTip(tooltip)
self.drag_start_position = None

View File

@@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget):
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Main layout
self.main_layout = QVBoxLayout(self)

View File

@@ -0,0 +1,467 @@
import ast
import os
from pathlib import Path
from typing import Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, Qt, Signal
from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel
from qtpy.QtWidgets import 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 MacroItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover for macro functions"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.macro_actions: list[Any] = []
self.button_rects: list[QRect] = []
self.current_macro_info = {}
def add_macro_action(self, action: Any) -> None:
"""Add an action for macro functions"""
self.macro_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.macro_actions.clear()
def 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
# Only show actions for macro functions (not directories)
item = index.model().itemFromIndex(index)
if not item or not item.data(Qt.ItemDataRole.UserRole):
return
macro_info = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(macro_info, dict) or "function_name" not in macro_info:
return
self.current_macro_info = macro_info
if self.macro_actions:
self._draw_action_buttons(painter, option, self.macro_actions)
def _draw_action_buttons(self, painter, option, actions: list[Any]):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
spacing = 2
# Calculate total width needed for all buttons
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
# Clear previous button rects and create new ones
self.button_rects.clear()
# Calculate starting position (right side of the item)
start_x = option.rect.right() - total_width - margin
current_x = start_x
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get theme colors for better integration
palette = get_theme_palette()
button_bg = palette.button().color()
button_bg.setAlpha(150) # Semi-transparent
for action in actions:
if not action.isVisible():
continue
# Calculate button position
button_rect = QRect(
current_x,
option.rect.top() + (option.rect.height() - button_size) // 2,
button_size,
button_size,
)
self.button_rects.append(button_rect)
# Draw button background
painter.setBrush(button_bg)
painter.setPen(palette.mid().color())
painter.drawRoundedRect(button_rect, 3, 3)
# Draw action icon
icon = action.icon()
if not icon.isNull():
icon_rect = button_rect.adjusted(2, 2, -2, -2)
icon.paint(painter, icon_rect)
# Move to next button position
current_x += button_size + spacing
painter.restore()
def 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)
# Check which button was clicked
visible_actions = [action for action in self.macro_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 MacroTreeWidget(QWidget):
"""A tree widget that displays macro functions from Python files"""
macro_selected = Signal(str, str) # Function name, file path
macro_open_requested = Signal(str, str) # Function name, file path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Disable editing to prevent renaming on double-click
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create model for macro functions
self.model = QStandardItemModel()
self.tree.setModel(self.model)
# Create and set custom delegate
self.delegate = MacroItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for macros
action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self)
action.action.triggered.connect(self._on_macro_open_requested)
self.delegate.add_macro_action(action.action)
# Apply BEC styling
self._apply_styling()
# Macro specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# Standard editable styling
opacity_modifier = ""
cursor_style = ""
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
{opacity_modifier}
{cursor_style}
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory):
"""Set the macros directory and scan for macro functions"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
self._scan_macro_functions()
def _create_file_item(self, py_file: Path) -> QStandardItem | None:
"""Create a file item with its functions
Args:
py_file: Path to the Python file
Returns:
QStandardItem representing the file, or None if no functions found
"""
# Skip files starting with underscore
if py_file.name.startswith("_"):
return None
try:
functions = self._extract_functions_from_file(py_file)
if not functions:
return None
# Create a file node
file_item = QStandardItem(py_file.stem)
file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole)
# Add function nodes
for func_name, func_info in functions.items():
func_item = QStandardItem(func_name)
func_data = {
"function_name": func_name,
"file_path": str(py_file),
"line_number": func_info.get("line_number", 1),
"type": "function",
}
func_item.setData(func_data, Qt.ItemDataRole.UserRole)
file_item.appendRow(func_item)
return file_item
except Exception as e:
logger.warning(f"Failed to parse {py_file}: {e}")
return None
def _scan_macro_functions(self):
"""Scan the directory for Python files and extract macro functions"""
self.model.clear()
self.model.setHorizontalHeaderLabels(["Macros"])
if not self.directory or not os.path.exists(self.directory):
return
# Get all Python files in the directory
python_files = list(Path(self.directory).glob("*.py"))
for py_file in python_files:
file_item = self._create_file_item(py_file)
if file_item:
self.model.appendRow(file_item)
self.tree.expandAll()
def _extract_functions_from_file(self, file_path: Path) -> dict:
"""Extract function definitions from a Python file"""
functions = {}
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the AST
tree = ast.parse(content)
# Only get top-level function definitions
for node in tree.body:
if isinstance(node, ast.FunctionDef):
functions[node.name] = {
"line_number": node.lineno,
"docstring": ast.get_docstring(node) or "",
}
except Exception as e:
logger.warning(f"Failed to parse {file_path}: {e}")
return functions
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
item = self.model.itemFromIndex(index)
if not item:
return
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
if data.get("type") == "function":
function_name = data.get("function_name")
file_path = data.get("file_path")
if function_name and file_path:
logger.info(f"Macro function selected: {function_name} in {file_path}")
self.macro_selected.emit(function_name, file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
item = self.model.itemFromIndex(index)
if not item:
return
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
if data.get("type") == "function":
function_name = data.get("function_name")
file_path = data.get("file_path")
if function_name and file_path:
logger.info(
f"Macro open requested via double-click: {function_name} in {file_path}"
)
self.macro_open_requested.emit(function_name, file_path)
def _on_macro_open_requested(self):
"""Handle macro open action triggered"""
logger.info("Macro open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
macro_info = self.delegate.current_macro_info
if not macro_info or macro_info.get("type") != "function":
return
function_name = macro_info.get("function_name")
file_path = macro_info.get("file_path")
if function_name and file_path:
self.macro_open_requested.emit(function_name, file_path)
def add_macro_action(self, action: Any) -> None:
"""Add an action for macro items"""
self.delegate.add_macro_action(action)
def clear_actions(self) -> None:
"""Remove all actions from items"""
self.delegate.clear_actions()
def refresh(self):
"""Refresh the tree view"""
if self.directory is None:
return
self._scan_macro_functions()
def refresh_file_item(self, file_path: str):
"""Refresh a single file item by re-scanning its functions
Args:
file_path: Path to the Python file to refresh
"""
if not file_path or not os.path.exists(file_path):
logger.warning(f"Cannot refresh file item: {file_path} does not exist")
return
py_file = Path(file_path)
# Find existing file item in the model
existing_item = None
existing_row = -1
for row in range(self.model.rowCount()):
item = self.model.item(row)
if not item or not item.data(Qt.ItemDataRole.UserRole):
continue
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file):
existing_item = item
existing_row = row
break
# Store expansion state if item exists
was_expanded = existing_item and self.tree.isExpanded(existing_item.index())
# Remove existing item if found
if existing_item and existing_row >= 0:
self.model.removeRow(existing_row)
# Create new item using the helper method
new_item = self._create_file_item(py_file)
if new_item:
# Insert at the same position or append if it was a new file
insert_row = existing_row if existing_row >= 0 else self.model.rowCount()
self.model.insertRow(insert_row, new_item)
# Restore expansion state
if was_expanded:
self.tree.expand(new_item.index())
else:
self.tree.expand(new_item.index())
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()

View File

@@ -3,7 +3,7 @@ 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.QtGui import QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
@@ -15,19 +15,20 @@ logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, tree_widget):
super().__init__(tree_widget)
self.setObjectName("file_item_delegate")
self.hovered_index = QModelIndex()
self.file_actions: list[QAction] = []
self.dir_actions: list[QAction] = []
self.button_rects: list[QRect] = []
self.file_actions = []
self.dir_actions = []
self.button_rects = []
self.current_file_path = ""
def add_file_action(self, action: QAction) -> None:
def add_file_action(self, action) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
def add_dir_action(self, action) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
@@ -67,7 +68,7 @@ class FileItemDelegate(QStyledItemDelegate):
if actions:
self._draw_action_buttons(painter, option, actions)
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
def _draw_action_buttons(self, painter, option, actions):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
@@ -229,12 +230,18 @@ class ScriptTreeWidget(QWidget):
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# Standard editable styling
opacity_modifier = ""
cursor_style = ""
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
{opacity_modifier}
{cursor_style}
}}
QTreeView::branch {{
border-image: none;
@@ -357,11 +364,11 @@ class ScriptTreeWidget(QWidget):
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
def add_file_action(self, action) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
def add_dir_action(self, action) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_action(action)

View File

@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = True
RPC = False
def __init__(
self,

View File

@@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "restart_alt"
RPC = True
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)

View File

@@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
RPC = True
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)

View File

@@ -0,0 +1,4 @@
from .device_table_view import DeviceTableView
from .dm_config_view import DMConfigView
from .dm_docstring_view import DocstringView
from .dm_ophyd_test import DMOphydTest

View File

@@ -0,0 +1,53 @@
import json
from typing import Any, Callable, Generator, Iterable, TypeVar
from bec_lib.utils.json import ExtendedEncoder
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.control.device_manager.components.constants import (
MIME_DEVICE_CONFIG,
SORT_KEY_ROLE,
)
_T = TypeVar("_T")
_RT = TypeVar("_RT")
def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]:
for v in vals:
try:
yield fn(v)
except BaseException:
pass
def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData:
"""Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG"""
mime_obj = QMimeData()
byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8"))
mime_obj.setData(MIME_DEVICE_CONFIG, byte_array)
return mime_obj
class SortableQListWidgetItem(QListWidgetItem):
"""Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with
custom widgets and this item."""
def __gt__(self, other):
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
other_key := other.data(SORT_KEY_ROLE)
) is None:
return False
return self_key.lower() > other_key.lower()
def __lt__(self, other):
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
other_key := other.data(SORT_KEY_ROLE)
) is None:
return False
return self_key.lower() < other_key.lower()
class SharedSelectionSignal(QObject):
proc = Signal(str)

View File

@@ -0,0 +1,3 @@
from .available_device_resources import AvailableDeviceResources
__all__ = ["AvailableDeviceResources"]

View File

@@ -0,0 +1,230 @@
from textwrap import dedent
from typing import NamedTuple
from uuid import uuid4
from bec_qthemes import material_icon
from qtpy.QtCore import QItemSelection, QSize, Signal
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import (
Ui_AvailableDeviceGroup,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
)
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
def _warning_string(spec: HashableDevice):
name_warning = (
"Device defined with multiple names! Please check:\n " + "\n ".join(spec.names)
if len(spec.names) > 1
else ""
)
source_warning = (
"Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files)
if len(spec._source_files) > 1
else ""
)
return f"{name_warning}{source_warning}"
class _DeviceEntryWidget(QFrame):
def __init__(self, device_spec: HashableDevice, parent=None, **kwargs):
super().__init__(parent, **kwargs)
self._device_spec = device_spec
self.included: bool = False
self.setFrameStyle(0)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(2, 2, 2, 2)
self.setLayout(self._layout)
self.setup_title_layout(device_spec)
self.check_and_display_warning()
self.setToolTip(self._rich_text())
def _rich_text(self):
return dedent(
f"""
<b><u><h2> {self._device_spec.name}: </h2></u></b>
<table>
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
<tr><td> config: </td><td><i> {self._device_spec.deviceConfig} </i></td></tr>
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
</table>
"""
)
def setup_title_layout(self, device_spec: HashableDevice):
self._title_layout = QHBoxLayout()
self._title_layout.setContentsMargins(0, 0, 0, 0)
self._title_container = QWidget(parent=self)
self._title_container.setLayout(self._title_layout)
self._warning_label = QLabel()
self._title_layout.addWidget(self._warning_label)
self.title = QLabel(device_spec.name)
self.title.setToolTip(device_spec.name)
self.title.setStyleSheet(self.title_style("#FF0000"))
self._title_layout.addWidget(self.title)
self._title_layout.addStretch(1)
self._layout.addWidget(self._title_container)
def check_and_display_warning(self):
if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1:
self._warning_label.setText("")
self._warning_label.setToolTip("")
else:
self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00"))
self._warning_label.setToolTip(_warning_string(self._device_spec))
@property
def device_hash(self):
return hash(self._device_spec)
def title_style(self, color: str) -> str:
return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}"
def setTitle(self, text: str):
self.title.setText(text)
def set_included(self, included: bool):
self.included = included
self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000"))
class _DeviceEntry(NamedTuple):
list_item: QListWidgetItem
widget: _DeviceEntryWidget
class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
selected_devices = Signal(list)
def __init__(
self,
parent=None,
name: str = "TagGroupTitle",
data: set[HashableDevice] = set(),
shared_selection_signal=SharedSelectionSignal(),
**kwargs,
):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self._shared_selection_signal = shared_selection_signal
self._shared_selection_uuid = str(uuid4())
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed)
self.title_text = name # type: ignore
self._mime_data = []
self._devices: dict[str, _DeviceEntry] = {}
for device in data:
self._add_item(device)
self.device_list.sortItems()
self.setMinimumSize(self.device_list.sizeHint())
self._update_num_included()
def _add_item(self, device: HashableDevice):
item = QListWidgetItem(self.device_list)
device_dump = device.model_dump(exclude_defaults=True)
item.setData(CONFIG_DATA_ROLE, device_dump)
self._mime_data.append(device_dump)
widget = _DeviceEntryWidget(device, self)
item.setSizeHint(QSize(widget.width(), widget.height()))
self.device_list.setItemWidget(item, widget)
self.device_list.addItem(item)
self._devices[device.name] = _DeviceEntry(item, widget)
def create_mime_data(self):
return self._mime_data
def reset_devices_state(self):
for dev in self._devices.values():
dev.widget.set_included(False)
self._update_num_included()
def set_item_state(self, /, device_hash: int, included: bool):
for dev in self._devices.values():
if dev.widget.device_hash == device_hash:
dev.widget.set_included(included)
self._update_num_included()
def _update_num_included(self):
n_included = sum(int(dev.widget.included) for dev in self._devices.values())
if n_included == 0:
color = "#FF0000"
elif n_included == len(self._devices):
color = "#00FF00"
else:
color = "#FFAA00"
self.n_included.setText(f"{n_included} / {len(self._devices)}")
self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}")
def sizeHint(self) -> QSize:
if not getattr(self, "device_list", None) or not self.expanded:
return super().sizeHint()
return QSize(
max(150, self.device_list.viewport().width()),
self.device_list.sizeHintForRow(0) * self.device_list.count() + 50,
)
@SafeSlot(QItemSelection, QItemSelection)
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
config = [dev.as_normal_device().model_dump() for dev in self.get_selection()]
self.selected_devices.emit(config)
@SafeSlot(str)
def _handle_shared_selection_signal(self, uuid: str):
if uuid != self._shared_selection_uuid:
self.device_list.clearSelection()
def resizeEvent(self, event):
super().resizeEvent(event)
self.setMinimumHeight(self.sizeHint().height())
self.setMaximumHeight(self.sizeHint().height())
def get_selection(self) -> set[HashableDevice]:
selection = self.device_list.selectedItems()
widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection)
return set(w._device_spec for w in widgets)
def __repr__(self) -> str:
return f"{self.__class__.__name__}: {self.title_text}"
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = AvailableDeviceGroup(name="Tag group 1")
for item in [
HashableDevice(
**{
"name": f"test_device_{i}",
"deviceClass": "TestDeviceClass",
"readoutPriority": "baseline",
"enabled": True,
}
)
for i in range(5)
]:
widget._add_item(item)
widget._update_num_included()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,56 @@
from typing import TYPE_CHECKING
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout
from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs
from bec_widgets.widgets.control.device_manager.components.constants import (
CONFIG_DATA_ROLE,
MIME_DEVICE_CONFIG,
)
if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWiget(QListWidget):
def _item_iter(self):
return (self.item(i) for i in range(self.count()))
def all_configs(self):
return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()]
def mimeTypes(self):
return [MIME_DEVICE_CONFIG]
def mimeData(self, items):
return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items)
class Ui_AvailableDeviceGroup(object):
def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"):
if not AvailableDeviceGroup.objectName():
AvailableDeviceGroup.setObjectName("AvailableDeviceGroup")
AvailableDeviceGroup.setMinimumWidth(150)
self.verticalLayout = QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
AvailableDeviceGroup.set_layout(self.verticalLayout)
title_layout = AvailableDeviceGroup.get_title_layout()
self.n_included = QLabel(AvailableDeviceGroup, text="...")
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)
self.device_list.setDragEnabled(True)
self.device_list.setAcceptDrops(False)
self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction)
self.verticalLayout.addWidget(self.device_list)
AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box)
QMetaObject.connectSlotsByName(AvailableDeviceGroup)

View File

@@ -0,0 +1,128 @@
from random import randint
from typing import Any, Iterable
from uuid import uuid4
from qtpy.QtCore import QItemSelection, Signal # type: ignore
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components._util import (
SharedSelectionSignal,
yield_only_passing,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import (
Ui_availableDeviceResources,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
get_backend,
)
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected
add_selected_devices = Signal(list)
del_selected_devices = Signal(list)
def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self._backend = get_backend()
self._shared_selection_signal = shared_selection_signal
self._shared_selection_uuid = str(uuid4())
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
self.device_groups_list.selectionModel().selectionChanged.connect(
self._on_selection_changed
)
self.grouping_selector.addItem("deviceTags")
self.grouping_selector.addItems(self._backend.allowed_sort_keys)
self._grouping_selection_changed("deviceTags")
self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed)
self.search_box.textChanged.connect(self.device_groups_list.update_filter)
self.tb_add_selected.action.triggered.connect(self._add_selected_action)
self.tb_del_selected.action.triggered.connect(self._del_selected_action)
def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]):
self.device_groups_list.clear()
for device_group, devices in device_groups.items():
self._add_device_group(device_group, devices)
if self.grouping_selector.currentText == "deviceTags":
self._add_device_group("Untagged devices", self._backend.untagged_devices)
self.device_groups_list.sortItems()
def _add_device_group(self, device_group: str, devices: set[HashableDevice]):
item, widget = self.device_groups_list.add_item(
device_group,
self.device_groups_list,
device_group,
devices,
shared_selection_signal=self._shared_selection_signal,
expanded=False,
)
item.setData(CONFIG_DATA_ROLE, widget.create_mime_data())
# Re-emit the selected items from a subgroup - all other selections should be disabled anyway
widget.selected_devices.connect(self.selected_devices)
def resizeEvent(self, event):
super().resizeEvent(event)
for list_item, device_group_widget in self.device_groups_list.item_widget_pairs():
list_item.setSizeHint(device_group_widget.sizeHint())
@SafeSlot()
def _add_selected_action(self):
self.add_selected_devices.emit(self.device_groups_list.any_selected_devices())
@SafeSlot()
def _del_selected_action(self):
self.del_selected_devices.emit(self.device_groups_list.any_selected_devices())
@SafeSlot(QItemSelection, QItemSelection)
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups())
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
@SafeSlot(str)
def _handle_shared_selection_signal(self, uuid: str):
if uuid != self._shared_selection_uuid:
self.device_groups_list.clearSelection()
def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
for device in devices:
for device_group in self.device_groups_list.widgets():
device_group.set_item_state(hash(device), included)
@SafeSlot(list)
def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool):
"""Set the display color of individual devices and update the group display of numbers
included. Accepts a list of dicts with the complete config as used in
bec_lib.atlas_models.Device."""
self._set_devices_state(
yield_only_passing(HashableDevice.model_validate, config_list), used
)
@SafeSlot(str)
def _grouping_selection_changed(self, sort_key: str):
self.search_box.setText("")
if sort_key == "deviceTags":
device_groups = self._backend.tag_groups
else:
device_groups = self._backend.group_by_key(sort_key)
self.refresh_full_list(device_groups)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = AvailableDeviceResources()
widget._set_devices_state(
list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True
)
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import itertools
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QComboBox,
QGridLayout,
QLabel,
QLineEdit,
QListView,
QListWidget,
QListWidgetItem,
QSizePolicy,
QVBoxLayout,
)
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import (
AvailableDeviceGroup,
)
from bec_widgets.widgets.control.device_manager.components.constants import (
CONFIG_DATA_ROLE,
MIME_DEVICE_CONFIG,
)
class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]):
def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup:
return super().itemWidget(item) # type: ignore
def any_selected_devices(self):
return self.selected_individual_devices() or self.selected_devices_from_groups()
def selected_individual_devices(self):
for widget in (self.itemWidget(self.item(i)) for i in range(self.count())):
if (selected := widget.get_selection()) != set():
return [dev.as_normal_device().model_dump() for dev in selected]
return []
def selected_devices_from_groups(self):
selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows())
widgets = (self.itemWidget(item) for item in selected_items)
return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets))
def mimeTypes(self):
return [MIME_DEVICE_CONFIG]
def mimeData(self, items):
return mimedata_from_configs(
itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items)
)
class Ui_availableDeviceResources(object):
def setupUi(self, availableDeviceResources):
if not availableDeviceResources.objectName():
availableDeviceResources.setObjectName("availableDeviceResources")
self.verticalLayout = QVBoxLayout(availableDeviceResources)
self.verticalLayout.setObjectName("verticalLayout")
self._add_toolbar()
# Main area with search and filter using a grid layout
self.search_layout = QVBoxLayout()
self.grid_layout = QGridLayout()
self.grouping_selector = QComboBox()
self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
lbl_group = QLabel("Group by:")
lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.grid_layout.addWidget(lbl_group, 0, 0)
self.grid_layout.addWidget(self.grouping_selector, 0, 1)
self.search_box = QLineEdit()
self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
lbl_filter = QLabel("Filter:")
lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.grid_layout.addWidget(lbl_filter, 1, 0)
self.grid_layout.addWidget(self.search_box, 1, 1)
self.grid_layout.setColumnStretch(0, 0)
self.grid_layout.setColumnStretch(1, 1)
self.search_layout.addLayout(self.grid_layout)
self.verticalLayout.addLayout(self.search_layout)
self.device_groups_list = _ListOfDeviceGroups(
availableDeviceResources, AvailableDeviceGroup
)
self.device_groups_list.setObjectName("device_groups_list")
self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.device_groups_list.setMovement(QListView.Movement.Static)
self.device_groups_list.setSpacing(4)
self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems)
self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_groups_list.setDragEnabled(True)
self.device_groups_list.setAcceptDrops(False)
self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction)
self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
availableDeviceResources.setMinimumWidth(250)
availableDeviceResources.resize(250, availableDeviceResources.height())
self.verticalLayout.addWidget(self.device_groups_list)
QMetaObject.connectSlotsByName(availableDeviceResources)
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
io_bundle = ToolbarBundle("IO", self.toolbar.components)
self.tb_add_selected = MaterialIconAction(
icon_name="add_box", parent=self, tooltip="Add selected devices to composition"
)
self.toolbar.components.add_safe("add_selected", self.tb_add_selected)
io_bundle.add_action("add_selected")
self.tb_del_selected = MaterialIconAction(
icon_name="chips", parent=self, tooltip="Remove selected devices from composition"
)
self.toolbar.components.add_safe("del_selected", self.tb_del_selected)
io_bundle.add_action("del_selected")
self.verticalLayout.addWidget(self.toolbar)
self.toolbar.add_bundle(io_bundle)
self.toolbar.show_bundles(["IO"])

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import operator
import os
from enum import Enum, auto
from functools import partial, reduce
from glob import glob
from pathlib import Path
from typing import Protocol
import bec_lib
from bec_lib.atlas_models import HashableDevice, HashableDeviceSet
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed
logger = bec_logger.logger
# use the last n recovery files
_N_RECOVERY_FILES = 3
_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.."
def get_backend() -> DeviceResourceBackend:
return _ConfigFileBackend()
class HashModel(str, Enum):
DEFAULT = auto()
DEFAULT_DEVICECONFIG = auto()
DEFAULT_EPICS = auto()
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all availble devices separated by tag groups. The same device may
appear more than once (in different groups)."""
...
@property
def all_devices(self) -> set[HashableDevice]:
"""A set of all availble devices. The same device may not appear more than once."""
...
@property
def untagged_devices(self) -> set[HashableDevice]:
"""A set of all untagged devices. The same device may not appear more than once."""
...
@property
def allowed_sort_keys(self) -> set[str]:
"""A set of all fields which you may group devices by"""
...
def tags(self) -> set[str]:
"""Returns a set of all the tags in all available devices."""
...
def tag_group(self, tag: str) -> set[HashableDevice]:
"""Returns a set of the devices in the tag group with the given key."""
...
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
"""Return a dict of all devices, organised by the specified key, which must be one of
the string keys in the Device model."""
...
def _devices_from_file(file: str, include_source: bool = True):
data = yaml_load(file, process_includes=False)
return HashableDeviceSet(
HashableDevice.model_validate(
dev | {"name": name, "source_files": {file} if include_source else set()}
)
for name, dev in data.items()
)
class _ConfigFileBackend(DeviceResourceBackend):
def __init__(self) -> None:
self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files()
if plugins_installed() == 1:
self._raw_device_set.update(
self._get_configs_from_plugin_files(
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
)
)
self._device_groups = self._get_tag_groups()
def _get_config_from_backup_files(self):
dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs"
files = sorted(glob("*.yaml", root_dir=dir))
last_n_files = files[-_N_RECOVERY_FILES:]
return reduce(
operator.or_,
map(
partial(_devices_from_file, include_source=False),
(str(dir / f) for f in last_n_files),
),
set(),
)
def _get_configs_from_plugin_files(self, dir: Path):
files = glob("*.yaml", root_dir=dir, recursive=True)
return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set())
def _get_tag_groups(self) -> dict[str, set[HashableDevice]]:
return {
tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set))
for tag in self.tags()
}
@property
def tag_groups(self):
return self._device_groups
@property
def all_devices(self):
return self._raw_device_set
@property
def untagged_devices(self):
return {d for d in self._raw_device_set if d.deviceTags == set()}
@property
def allowed_sort_keys(self) -> set[str]:
return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str}
def tags(self) -> set[str]:
return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set())
def tag_group(self, tag: str) -> set[HashableDevice]:
return self.tag_groups[tag]
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
if key not in self.allowed_sort_keys:
raise ValueError(f"Cannot group available devices by model key {key}")
group_names: set[str] = {getattr(item, key) for item in self._raw_device_set}
return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names}

View File

@@ -0,0 +1,8 @@
from typing import Final
# Denotes a MIME type for JSON-encoded list of device config dictionaries
MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config"
# Custom user roles
SORT_KEY_ROLE: Final[int] = 117
CONFIG_DATA_ROLE: Final[int] = 118

View File

@@ -0,0 +1,100 @@
"""Module with a config view for the device manager."""
from __future__ import annotations
import traceback
import yaml
from bec_lib.logger import bec_logger
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class DMConfigView(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.setLayout(self.stacked_layout)
# Monaco widget
self.monaco_editor = MonacoWidget()
self._customize_monaco()
self.stacked_layout.addWidget(self.monaco_editor)
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_monaco(self):
self.monaco_editor.set_language("yaml")
self.monaco_editor.set_vim_mode_enabled(False)
self.monaco_editor.set_minimap_enabled(False)
# self.monaco_editor.setFixedHeight(600)
self.monaco_editor.set_readonly(True)
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
self.monaco_editor.editor.set_line_numbers_mode("off")
def _customize_overlay(self):
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
@SafeSlot(dict)
def on_select_config(self, device: list[dict]):
"""Handle selection of a device from the device table."""
if len(device) != 1:
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
else:
try:
text = yaml.dump(device[0], default_flow_style=False)
self.stacked_layout.setCurrentWidget(self.monaco_editor)
except Exception:
content = traceback.format_exc()
logger.error(f"Error converting device to YAML:\n{content}")
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
self.monaco_editor.set_readonly(False) # Enable editing
text = text.rstrip()
self.monaco_editor.set_text(text)
self.monaco_editor.set_readonly(True) # Disable editing again
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
config_view = DMConfigView()
layout.addWidget(config_view)
combo_box = QtWidgets.QComboBox()
config = config_view.client.device_manager._get_redis_device_config()
combo_box.addItems([""] + [str(v) for v, item in enumerate(config)])
def on_select(text):
if text == "":
config_view.on_select_config([])
else:
config_view.on_select_config([config[int(text)]])
combo_box.currentTextChanged.connect(on_select)
layout.addWidget(combo_box)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,133 @@
"""Module to visualize the docstring of a device class."""
from __future__ import annotations
import inspect
import re
import textwrap
import traceback
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
from bec_lib.utils.rpc_utils import rgetattr
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
try:
import ophyd
import ophyd_devices
READY_TO_VIEW = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
ophyd = None
def docstring_to_markdown(obj) -> str:
"""
Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown.
"""
raw = inspect.getdoc(obj) or "*No docstring available.*"
# Dedent and normalize newlines
text = textwrap.dedent(raw).strip()
md = ""
if hasattr(obj, "__name__"):
md += f"# {obj.__name__}\n\n"
# Highlight section headers for Markdown
headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"]
for h in headers:
doc = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text)
# Preserve code blocks (4+ space indented lines)
def fence_code(match: re.Match) -> str:
block = re.sub(r"^ {4}", "", match.group(0), flags=re.M)
return f"```\n{block}\n```"
doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text)
# Preserve normal line breaks for Markdown
lines = doc.splitlines()
processed_lines = []
for line in lines:
if line.strip() == "":
processed_lines.append("")
else:
processed_lines.append(line + " ")
doc = "\n".join(processed_lines)
md += doc
return md
class DocstringView(QtWidgets.QTextEdit):
def __init__(self, parent: QtWidgets.QWidget | None = None):
super().__init__(parent)
self.setReadOnly(True)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
if not READY_TO_VIEW:
self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.")
self.setEnabled(False)
return
def _set_text(self, text: str):
self.setReadOnly(False)
self.setMarkdown(text)
self.setReadOnly(True)
@SafeSlot(list)
def on_select_config(self, device: list[dict]):
if len(device) != 1:
self._set_text("")
return
device_class = device[0].get("deviceClass", "")
self.set_device_class(device_class)
@SafeSlot(str)
def set_device_class(self, device_class_str: str) -> None:
if not READY_TO_VIEW:
return
try:
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
markdown = docstring_to_markdown(module_cls)
self._set_text(markdown)
except Exception:
logger.exception("Error retrieving docstring")
self._set_text(f"*Error retrieving docstring for `{device_class_str}`*")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
config_view = DocstringView()
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
layout.addWidget(config_view)
combo = QtWidgets.QComboBox()
combo.addItems(
[
"",
"ophyd_devices.sim.sim_camera.SimCamera",
"ophyd.EpicsSignalWithRBV",
"ophyd.EpicsMotor",
"csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS",
]
)
combo.currentTextChanged.connect(config_view.set_device_class)
layout.addWidget(combo)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,410 @@
"""Module to run a static tests for devices from a yaml config."""
from __future__ import annotations
import enum
import re
from collections import deque
from concurrent.futures import CancelledError, Future, ThreadPoolExecutor
from html import escape
from threading import Event, RLock
from typing import Any, Iterable
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
READY_TO_TEST = False
logger = bec_logger.logger
try:
import bec_server
import ophyd_devices
READY_TO_TEST = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
bec_server = None
try:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
except ImportError:
StaticDeviceTest = None
class ValidationStatus(int, enum.Enum):
"""Validation status for device configurations."""
PENDING = 0 # colors.default
VALID = 1 # colors.highlight
FAILED = 2 # colors.emergency
class DeviceValidationResult(QtCore.QObject):
"""Simple object to inject validation signals into QRunnable."""
# Device validation signal, device_name, ValidationStatus as int, error message or ''
device_validated = QtCore.Signal(str, bool, str)
class DeviceTester(QtCore.QRunnable):
def __init__(self, config: dict) -> None:
super().__init__()
self.signals = DeviceValidationResult()
self.shutdown_event = Event()
self._config = config
self._max_threads = 4
self._pending_event = Event()
self._lock = RLock()
self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester")
self._pending_queue: deque[tuple[str, dict]] = deque([])
self._active: set[str] = set()
QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set())
def run(self):
if StaticDeviceTest is None:
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
return
while not self.shutdown_event.is_set():
self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s
if len(self._active) >= self._max_threads:
self._pending_event.clear() # it will be set again on removing something from active
continue
with self._lock:
if len(self._pending_queue) > 0:
item, cfg, connect = self._pending_queue.pop()
self._active.add(item)
fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect)
fut.__dict__["__device_name"] = item
fut.add_done_callback(self._done_cb)
self._safe_check_and_clear()
self._cleanup()
def submit(self, devices: Iterable[tuple[str, dict, bool]]):
with self._lock:
self._pending_queue.extend(devices)
self._pending_event.set()
@staticmethod
def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]:
tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None
results = tester.run_with_list_output(connect=connect)
return name, results[0].success, results[0].message
def _safe_check_and_clear(self):
with self._lock:
if len(self._pending_queue) == 0:
self._pending_event.clear()
def _safe_remove_from_active(self, name: str):
with self._lock:
self._active.remove(name)
self._pending_event.set() # check again once a completed task is removed
def _done_cb(self, future: Future):
try:
name, success, message = future.result()
except CancelledError:
return
except Exception as e:
name, success, message = future.__dict__["__device_name"], False, str(e)
finally:
self._safe_remove_from_active(future.__dict__["__device_name"])
self.signals.device_validated.emit(name, success, message)
def _cleanup(self): ...
class ValidationListItem(QtWidgets.QWidget):
"""Custom list item widget showing device name and validation status."""
def __init__(self, device_name: str, device_config: dict, parent=None):
"""
Initialize the validation list item.
Args:
device_name (str): The name of the device.
device_config (dict): The configuration of the device.
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
parent (QtWidgets.QWidget, optional): The parent widget.
"""
super().__init__(parent)
self.main_layout = QtWidgets.QHBoxLayout(self)
self.main_layout.setContentsMargins(2, 2, 2, 2)
self.main_layout.setSpacing(4)
self.device_name = device_name
self.device_config = device_config
self.validation_msg = "Validation in progress..."
self._setup_ui()
def _setup_ui(self):
"""Setup the UI for the list item."""
label = QtWidgets.QLabel(self.device_name)
self.main_layout.addWidget(label)
self.main_layout.addStretch()
self._spinner = SpinnerWidget(parent=self)
self._spinner.speed = 80
self._spinner.setFixedSize(24, 24)
self.main_layout.addWidget(self._spinner)
self._base_style = "font-weight: bold;"
self.setStyleSheet(self._base_style)
self._start_spinner()
def _start_spinner(self):
"""Start the spinner animation."""
self._spinner.start()
def _stop_spinner(self):
"""Stop the spinner animation."""
self._spinner.stop()
self._spinner.setVisible(False)
@SafeSlot()
def on_validation_restart(self):
"""Handle validation restart."""
self.validation_msg = ""
self._start_spinner()
self.setStyleSheet("") # Check if this works as expected
@SafeSlot(str)
def on_validation_failed(self, error_msg: str):
"""Handle validation failure."""
self.validation_msg = error_msg
colors = get_accent_colors()
self._stop_spinner()
self.main_layout.removeWidget(self._spinner)
self._spinner.deleteLater()
label = QtWidgets.QLabel("")
icon = material_icon("error", color=colors.emergency, size=(24, 24))
label.setPixmap(icon)
self.main_layout.addWidget(label)
class DMOphydTest(BECWidget, QtWidgets.QWidget):
"""Widget to test device configurations using ophyd devices."""
# Signal to emit the validation status of a device
device_validated = QtCore.Signal(str, int)
# validation_msg in markdown format
validation_msg_md = QtCore.Signal(str)
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
if not READY_TO_TEST:
self.setDisabled(True)
self.tester = None
else:
self.tester = DeviceTester({})
self.tester.signals.device_validated.connect(self._on_device_validated)
QtCore.QThreadPool.globalInstance().start(self.tester)
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
# TODO Consider using the thread pool from BECConnector instead of fetching the global instance!
self._thread_pool = QtCore.QThreadPool.globalInstance()
self._main_layout = QtWidgets.QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(0)
# We add a splitter between the list and the text box
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
self._main_layout.addWidget(self.splitter)
self._setup_list_ui()
def _setup_list_ui(self):
"""Setup the list UI."""
self._list_widget = QtWidgets.QListWidget(self)
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.splitter.addWidget(self._list_widget)
# Connect signals
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
@SafeSlot(list, bool)
@SafeSlot(list, bool, bool)
def change_device_configs(
self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False
) -> None:
"""Receive an update with device configs.
Args:
device_configs (list[dict[str, Any]]): The updated device configurations.
"""
for cfg in device_configs:
name = cfg.get("name", "<not found>")
if added:
if name in self._device_list_items:
continue
if self.tester:
self._add_device(name, cfg)
self.tester.submit([(name, cfg, connect)])
continue
if name not in self._device_list_items:
continue
self._remove_list_item(name)
def _add_device(self, name, cfg):
item = QtWidgets.QListWidgetItem(self._list_widget)
widget = ValidationListItem(device_name=name, device_config=cfg)
# wrap it in a QListWidgetItem
item.setSizeHint(widget.sizeHint())
self._list_widget.addItem(item)
self._list_widget.setItemWidget(item, widget)
self._device_list_items[name] = item
def _remove_list_item(self, device_name: str):
"""Remove a device from the list."""
# Get the list item
item = self._device_list_items.pop(device_name)
# Retrieve the custom widget attached to the item
widget = self._list_widget.itemWidget(item)
if widget is not None:
widget.deleteLater() # clean up custom widget
# Remove the item from the QListWidget
row = self._list_widget.row(item)
self._list_widget.takeItem(row)
@SafeSlot(str, bool, str)
def _on_device_validated(self, device_name: str, success: bool, message: str):
"""Handle the device validation result.
Args:
device_name (str): The name of the device.
success (bool): Whether the validation was successful.
message (str): The validation message.
"""
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
item = self._device_list_items.get(device_name, None)
if not item:
logger.error(f"Device {device_name} not found in the list.")
return
if success:
self._remove_list_item(device_name=device_name)
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
else:
widget: ValidationListItem = self._list_widget.itemWidget(item)
widget.on_validation_failed(message)
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
def _on_current_item_changed(
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
):
"""Handle the current item change in the list widget.
Args:
current (QListWidgetItem): The currently selected item.
previous (QListWidgetItem): The previously selected item.
"""
widget: ValidationListItem = self._list_widget.itemWidget(current)
if widget:
try:
formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg)
self.validation_msg_md.emit(formatted_md)
except Exception as e:
logger.error(
f"##Error formatting validation message for device {widget.device_name}:\n{e}"
)
self.validation_msg_md.emit(widget.validation_msg)
else:
self.validation_msg_md.emit("")
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
"""Simple HTML formatting for validation messages, wrapping text naturally."""
if not raw_msg.strip():
return f"### Validation in progress for {device_name}... \n\n"
if raw_msg == "Validation in progress...":
return f"### Validation in progress for {device_name}... \n\n"
m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg)
device, summary = m.group(1), m.group(2)
lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"]
# Find each field block: \n<field>\n Field required ...
field_pat = re.compile(
r"\n(?P<field>\w+)\n\s+(?P<rest>Field required.*?(?=\n\w+\n|$))", re.DOTALL
)
for m in field_pat.finditer(raw_msg):
field = m.group("field")
rest = m.group("rest").rstrip()
lines.append(f"### {field}")
lines.append(rest)
return "\n".join(lines)
def validation_running(self):
return self._device_list_items != {}
@SafeSlot()
def clear_list(self):
"""Clear the device list."""
self._thread_pool.clear()
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
logger.error("Failed to wait for threads to finish. Removing items from the list.")
self._device_list_items.clear()
self._list_widget.clear()
self.validation_msg_md.emit("")
def remove_device(self, device_name: str):
"""Remove a device from the list."""
item = self._device_list_items.pop(device_name, None)
if item:
self._list_widget.removeItemWidget(item)
def cleanup(self):
if self.tester:
self.tester.shutdown_event.set()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_lib.bec_yaml_loader import yaml_load
# pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
wid = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(wid)
wid.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
device_manager_ophyd_test = DMOphydTest()
try:
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
except Exception as e:
logger.error(f"Error loading config: {e}")
import os
import bec_lib
config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
device_manager_ophyd_test.change_device_configs(config, True, True)
layout.addWidget(device_manager_ophyd_test)
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
device_manager_ophyd_test.resize(800, 600)
text_box = QtWidgets.QTextEdit()
text_box.setReadOnly(True)
layout.addWidget(text_box)
device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown)
wid.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,452 @@
from __future__ import annotations
import os
import pathlib
from typing import Any, Literal, cast
import PySide6QtAds as QtAds
from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class MonacoDock(BECWidget, QWidget):
"""
MonacoDock is a dock widget that contains Monaco editor instances.
It is used to manage multiple Monaco editors in a dockable interface.
"""
focused_editor = Signal(object) # Emitted when the focused editor changes
save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled
signature_help = Signal(str) # Emitted when signature help is requested
macro_file_updated = Signal(str) # Emitted when a macro file is saved
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = QtAds.CDockManager(self)
self.dock_manager.setStyleSheet("")
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self._root_layout.addWidget(self.dock_manager)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: MonacoWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
self.add_editor()
self._open_files = {}
def _create_editor(self):
widget = MonacoWidget(self)
widget.save_enabled.connect(self.save_enabled.emit)
widget.editor.signature_help_triggered.connect(self._on_signature_change)
count = len(self.dock_manager.dockWidgets())
dock = CDockWidget(f"Untitled_{count + 1}")
dock.setWidget(widget)
# Connect to modification status changes to update tab titles
widget.save_enabled.connect(
lambda modified: self._update_tab_title_for_modification(dock, modified)
)
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetClosable, True)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, True)
dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget))
return dock
@property
def last_focused_editor(self) -> CDockWidget | None:
"""
Get the last focused editor.
"""
return self._last_focused_editor
@last_focused_editor.setter
def last_focused_editor(self, editor: CDockWidget | None):
self._last_focused_editor = editor
self.focused_editor.emit(editor)
def _on_last_focused_editor_changed(self, editor: CDockWidget | None):
if editor is None:
self.save_enabled.emit(False)
return
widget = cast(MonacoWidget, editor.widget())
if widget.modified:
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
self.save_enabled.emit(widget.modified)
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
"""Update the tab title to show modification status with a dot indicator."""
current_title = dock.windowTitle()
# Remove existing modification indicator (dot and space)
if current_title.startswith(""):
base_title = current_title[2:] # Remove "• "
else:
base_title = current_title
# Add or remove the modification indicator
if modified:
new_title = f"{base_title}"
else:
new_title = base_title
dock.setWindowTitle(new_title)
def _on_signature_change(self, signature: dict):
signatures = signature.get("signatures", [])
if not signatures:
self.signature_help.emit("")
return
active_sig = signatures[signature.get("activeSignature", 0)]
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
# Get signature label and documentation
label = active_sig.get("label", "")
doc_obj = active_sig.get("documentation", {})
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
# Format the markdown output
markdown = f"```python\n{label}\n```\n\n{documentation}"
self.signature_help.emit(markdown)
def _on_focus_event(self, old_widget, new_widget) -> None:
# Track focus events for the dock widget
widget = new_widget.widget()
if isinstance(widget, MonacoWidget):
self.last_focused_editor = new_widget
def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget):
# Cast widget to MonacoWidget since we know that's what it is
monaco_widget = cast(MonacoWidget, widget)
# Check if we have unsaved changes
if monaco_widget.modified:
# Prompt the user to save changes
response = QMessageBox.question(
self,
"Unsaved Changes",
"You have unsaved changes. Do you want to save them?",
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
| QMessageBox.StandardButton.Cancel,
)
if response == QMessageBox.StandardButton.Yes:
self.save_file(monaco_widget)
elif response == QMessageBox.StandardButton.Cancel:
return
# Count all editor docks managed by this dock manager
total = len(self.dock_manager.dockWidgets())
if total <= 1:
# Do not remove the last dock; just wipe its editor content
# Temporarily disable read-only mode if the editor is read-only
# so we can clear the content for reuse
monaco_widget.set_readonly(False)
monaco_widget.set_text("")
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
return
# Otherwise, proceed to close and delete the dock
monaco_widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
if self.last_focused_editor is dock:
self.last_focused_editor = None
# After topology changes, make sure single-tab areas get a plus button
QTimer.singleShot(0, self._scan_and_fix_areas)
def _ensure_area_plus(self, area):
if area is None:
return
# Only add once per area
if getattr(area, "_monaco_plus_btn", None) is not None:
return
# If the area has exactly one tab, inject a + button next to the tab bar
try:
tabbar = area.titleBar().tabBar()
count = tabbar.count() if hasattr(tabbar, "count") else 1
except Exception:
count = 1
if count >= 1:
plus_btn = QToolButton(area)
plus_btn.setText("+")
plus_btn.setToolTip("New Monaco Editor")
plus_btn.setAutoRaise(True)
tb = area.titleBar()
idx = tb.indexOf(tb.tabBar())
tb.insertWidget(idx + 1, plus_btn)
plus_btn.clicked.connect(lambda: self.add_editor(area))
# pylint: disable=protected-access
area._monaco_plus_btn = plus_btn
def _scan_and_fix_areas(self):
# Find all dock areas under this manager and ensure each single-tab area has a plus button
areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget)
for a in areas:
self._ensure_area_plus(a)
def eventFilter(self, obj, event):
# Track dock manager events
if obj is self.dock_manager and event.type() in (
QEvent.Type.ChildAdded,
QEvent.Type.ChildRemoved,
QEvent.Type.LayoutRequest,
):
QTimer.singleShot(0, self._scan_and_fix_areas)
return super().eventFilter(obj, event)
def add_editor(
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
): # Any as qt ads does not return a proper type
"""
Adds a new Monaco editor dock widget to the dock manager.
"""
new_dock = self._create_editor()
if title is not None:
new_dock.setWindowTitle(title)
if tooltip is not None:
new_dock.setTabToolTip(tooltip)
if area is None:
area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock)
self._ensure_area_plus(area_obj)
else:
# If an area is provided, add the dock to that area
self.dock_manager.addDockWidgetTabToArea(new_dock, area)
self._ensure_area_plus(area)
QTimer.singleShot(0, self._scan_and_fix_areas)
return new_dock
def open_file(self, file_name: str, scope: str | None = None) -> None:
"""
Open a file in the specified area. If the file is already open, activate it.
"""
open_files = self._get_open_files()
if file_name in open_files:
dock = self._get_editor_dock(file_name)
if dock is not None:
dock.setAsCurrentTab()
return
file = os.path.basename(file_name)
# If the current editor is empty, we reuse it
# For now, the dock manager is only for the editor docks. We can therefore safely assume
# that all docks are editor docks.
dock_area = self.dock_manager.dockArea(0)
editor_dock = dock_area.currentDockWidget()
editor_widget = editor_dock.widget() if editor_dock else None
if editor_widget:
editor_widget = cast(MonacoWidget, editor_dock.widget())
if editor_widget.current_file is None and editor_widget.get_text() == "":
editor_dock.setWindowTitle(file)
editor_dock.setTabToolTip(file_name)
editor_widget.open_file(file_name)
editor_widget.metadata["scope"] = scope
return
# File is not open, create a new editor
editor_dock = self.add_editor(title=file, tooltip=file_name)
widget = cast(MonacoWidget, editor_dock.widget())
widget.open_file(file_name)
widget.metadata["scope"] = scope
def save_file(
self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True
) -> None:
"""
Save the currently focused file.
Args:
widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used.
force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved.
"""
if widget is None:
widget = self.last_focused_editor.widget() if self.last_focused_editor else None
if not widget:
return
if "macros" in widget.metadata.get("scope", ""):
if not self._validate_macros(widget.get_text()):
return
if widget.current_file and not force_save_as:
if format_on_save and pathlib.Path(widget.current_file).suffix == ".py":
widget.format()
with open(widget.current_file, "w", encoding="utf-8") as f:
f.write(widget.get_text())
if "macros" in widget.metadata.get("scope", ""):
self._update_macros(widget)
# Emit signal to refresh macro tree widget
self.macro_file_updated.emit(widget.current_file)
# pylint: disable=protected-access
widget._original_content = widget.get_text()
widget.save_enabled.emit(False)
return
# Save as option
save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)")
if save_file:
# check if we have suffix specified
file = pathlib.Path(save_file[0])
if file.suffix == "":
file = file.with_suffix(".py")
if format_on_save and file.suffix == ".py":
widget.format()
text = widget.get_text()
with open(file, "w", encoding="utf-8") as f:
f.write(text)
widget._original_content = text
# Update the current_file before emitting save_enabled to ensure proper tracking
widget._current_file = str(file)
widget.save_enabled.emit(False)
# Find the dock widget containing this monaco widget and update title
for dock in self.dock_manager.dockWidgets():
if dock.widget() == widget:
dock.setWindowTitle(file.name)
dock.setTabToolTip(str(file))
break
if "macros" in widget.metadata.get("scope", ""):
self._update_macros(widget)
# Emit signal to refresh macro tree widget
self.macro_file_updated.emit(str(file))
print(f"Save file called, last focused editor: {self.last_focused_editor}")
def _validate_macros(self, source: str) -> bool:
# pylint: disable=protected-access
# Ensure the macro does not contain executable code before saving
exec_code, line_number = has_executable_code(source)
if exec_code:
if line_number is None:
msg = "The macro contains executable code. Please remove it before saving."
else:
msg = f"The macro contains executable code on line {line_number}. Please remove it before saving."
QMessageBox.warning(self, "Save Error", msg)
return False
return True
def _update_macros(self, widget: MonacoWidget):
# pylint: disable=protected-access
if not widget.current_file:
return
# Check which macros have changed and broadcast the change
macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file)
existing_macros = self.client.macros._update_handler.get_existing_macros(
widget.current_file
)
removed_macros = set(existing_macros.keys()) - set(macros.keys())
added_macros = set(macros.keys()) - set(existing_macros.keys())
for name, info in macros.items():
if name in added_macros:
self.client.macros._update_handler.broadcast(
action="add", name=name, file_path=widget.current_file
)
if (
name in existing_macros
and info.get("source", "") != existing_macros[name]["source"]
):
self.client.macros._update_handler.broadcast(
action="reload", name=name, file_path=widget.current_file
)
for name in removed_macros:
self.client.macros._update_handler.broadcast(action="remove", name=name)
def set_vim_mode(self, enabled: bool):
"""
Set Vim mode for all editor widgets.
Args:
enabled (bool): Whether to enable or disable Vim mode.
"""
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
editor_widget.set_vim_mode_enabled(enabled)
def _get_open_files(self) -> list[str]:
open_files = []
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file is not None:
open_files.append(editor_widget.current_file)
return open_files
def _get_editor_dock(self, file_name: str) -> CDockWidget | None:
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file == file_name:
return widget
return None
def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool:
"""
Set a specific file's editor to read-only mode.
Args:
file_name (str): The file path to set read-only
read_only (bool): Whether to set read-only mode (default: True)
Returns:
bool: True if the file was found and read-only was set, False otherwise
"""
editor_dock = self._get_editor_dock(file_name)
if editor_dock:
editor_widget = cast(MonacoWidget, editor_dock.widget())
editor_widget.set_readonly(read_only)
return True
return False
def set_file_icon(self, file_name: str, icon) -> bool:
"""
Set an icon for a specific file's tab.
Args:
file_name (str): The file path to set icon for
icon: The QIcon to set on the tab
Returns:
bool: True if the file was found and icon was set, False otherwise
"""
editor_dock = self._get_editor_dock(file_name)
if editor_dock:
editor_dock.setIcon(icon)
return True
return False
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
_dock = MonacoDock()
_dock.show()
sys.exit(app.exec())

View File

@@ -1,11 +1,19 @@
import os
import traceback
from typing import Literal
import black
import isort
import qtmonaco
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_name
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class MonacoWidget(BECWidget, QWidget):
@@ -14,6 +22,7 @@ class MonacoWidget(BECWidget, QWidget):
"""
text_changed = Signal(str)
save_enabled = Signal(bool)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
@@ -21,6 +30,7 @@ class MonacoWidget(BECWidget, QWidget):
"get_text",
"insert_text",
"delete_line",
"open_file",
"set_language",
"get_language",
"set_theme",
@@ -47,7 +57,20 @@ class MonacoWidget(BECWidget, QWidget):
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.text_changed.connect(self._check_save_status)
self.editor.initialized.connect(self.apply_theme)
self.editor.initialized.connect(self._setup_context_menu)
self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action)
self._current_file = None
self._original_content = ""
self.metadata = {}
@property
def current_file(self):
"""
Get the current file being edited.
"""
return self._current_file
def apply_theme(self, theme: str | None = None) -> None:
"""
@@ -61,14 +84,17 @@ class MonacoWidget(BECWidget, QWidget):
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str) -> None:
def set_text(self, text: str, file_name: str | None = None) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
file_name (str): Set the file name
"""
self.editor.set_text(text)
self._current_file = file_name
self._original_content = text
self.editor.set_text(text, uri=file_name)
def get_text(self) -> str:
"""
@@ -76,6 +102,32 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_text()
def format(self) -> None:
"""
Format the current text in the Monaco editor.
"""
if not self.editor:
return
try:
content = self.get_text()
try:
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
except Exception: # black.NothingChanged or other formatting exceptions
formatted_content = content
config = isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
self.set_text(formatted_content, file_name=self.current_file)
except Exception:
content = traceback.format_exc()
logger.info(content)
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
@@ -96,6 +148,32 @@ class MonacoWidget(BECWidget, QWidget):
"""
self.editor.delete_line(line)
def open_file(self, file_name: str) -> None:
"""
Open a file in the editor.
Args:
file_name (str): The path + file name of the file that needs to be displayed.
"""
if not os.path.exists(file_name):
raise FileNotFoundError(f"The specified file does not exist: {file_name}")
with open(file_name, "r", encoding="utf-8") as file:
content = file.read()
self.set_text(content, file_name=file_name)
@property
def modified(self) -> bool:
"""
Check if the editor content has been modified.
"""
return self._original_content != self.get_text()
@SafeSlot(str)
def _check_save_status(self, _text: str) -> None:
self.save_enabled.emit(self.modified)
def set_cursor(
self,
line: int,
@@ -213,6 +291,36 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_lsp_header()
def _setup_context_menu(self):
"""Setup custom context menu actions for the Monaco editor."""
# Add the "Insert Scan" action to the context menu
self.editor.add_action("insert_scan", "Insert Scan", "python")
# Add the "Format Code" action to the context menu
self.editor.add_action("format_code", "Format Code", "python")
def _handle_context_menu_action(self, action_id: str):
"""Handle context menu action triggers."""
if action_id == "insert_scan":
self._show_scan_control_dialog()
elif action_id == "format_code":
self._format_code()
def _show_scan_control_dialog(self):
"""Show the scan control dialog and insert the generated scan code."""
# Import here to avoid circular imports
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
dialog = ScanControlDialog(self, client=self.client)
if dialog.exec_() == QDialog.DialogCode.Accepted:
scan_code = dialog.get_scan_code()
if scan_code:
# Insert the scan code at the current cursor position
self.insert_text(scan_code)
def _format_code(self):
"""Format the current code in the editor."""
self.format()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])
@@ -234,7 +342,7 @@ if TYPE_CHECKING:
scans: Scans
#######################################
########## User Script #####################
########## User Script ################
#######################################
# This is a comment

View File

@@ -0,0 +1,145 @@
"""
Scan Control Dialog for Monaco Editor
This module provides a dialog wrapper around the ScanControl widget,
allowing users to configure and generate scan code that can be inserted
into the Monaco editor.
"""
from bec_lib.device import Device
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout
from bec_widgets.widgets.control.scan_control import ScanControl
logger = bec_logger.logger
class ScanControlDialog(QDialog):
"""
Dialog window containing the ScanControl widget for generating scan code.
This dialog allows users to configure scan parameters and generates
Python code that can be inserted into the Monaco editor.
"""
def __init__(self, parent=None, client=None):
super().__init__(parent)
self.setWindowTitle("Insert Scan")
# Store the client for passing to ScanControl
self.client = client
self._scan_code = ""
self._setup_ui()
def sizeHint(self) -> QSize:
return QSize(600, 800)
def _setup_ui(self):
"""Setup the dialog UI with ScanControl widget and buttons."""
layout = QVBoxLayout(self)
# Create the scan control widget
self.scan_control = ScanControl(parent=self, client=self.client)
self.scan_control.show_scan_control_buttons(False)
layout.addWidget(self.scan_control)
# Create dialog buttons
button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self)
# Create custom buttons with appropriate text
insert_button = QPushButton("Insert")
cancel_button = QPushButton("Cancel")
button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
layout.addWidget(button_box)
# Connect button signals
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
def _generate_scan_code(self):
"""Generate Python code for the configured scan."""
try:
# Get scan parameters from the scan control widget
args, kwargs = self.scan_control.get_scan_parameters()
scan_name = self.scan_control.current_scan
if not scan_name:
self._scan_code = ""
return
# Process arguments and add device prefix where needed
processed_args = self._process_arguments_for_code_generation(args)
processed_kwargs = self._process_kwargs_for_code_generation(kwargs)
# Generate the Python code string
code_parts = []
# Process arguments and keyword arguments
all_args = []
# Add positional arguments
if processed_args:
all_args.extend(processed_args)
# Add keyword arguments (excluding metadata)
if processed_kwargs:
kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"]
all_args.extend(kwargs_strs)
# Join all arguments and create the scan call
args_str = ", ".join(all_args)
if args_str:
code_parts.append(f"scans.{scan_name}({args_str})")
else:
code_parts.append(f"scans.{scan_name}()")
self._scan_code = "\n".join(code_parts)
except Exception as e:
logger.error(f"Error generating scan code: {e}")
self._scan_code = f"# Error generating scan code: {e}\n"
def _process_arguments_for_code_generation(self, args):
"""Process arguments to add device prefixes and proper formatting."""
return [self._format_value_for_code(arg) for arg in args]
def _process_kwargs_for_code_generation(self, kwargs):
"""Process keyword arguments to add device prefixes and proper formatting."""
return {key: self._format_value_for_code(value) for key, value in kwargs.items()}
def _format_value_for_code(self, value):
"""Format a single value for code generation."""
if isinstance(value, Device):
return f"dev.{value.name}"
return repr(value)
def get_scan_code(self) -> str:
"""
Get the generated scan code.
Returns:
str: The Python code for the configured scan.
"""
return self._scan_code
def accept(self):
"""Override accept to generate code before closing."""
self._generate_scan_code()
super().accept()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = ScanControlDialog()
dialog.show()
sys.exit(app.exec_())

View File

@@ -172,9 +172,17 @@ class WebConsole(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
def __init__(
self,
parent=None,
config=None,
client=None,
gui_id=None,
startup_cmd="bec --nogui",
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = "bec --nogui"
self._startup_cmd = startup_cmd
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from collections import defaultdict
from typing import Literal
from typing import Literal, Sequence
import numpy as np
from bec_lib import bec_logger
@@ -309,7 +309,7 @@ class Image(ImageBase):
Set the image source and update the image.
Args:
monitor(str): The name of the monitor to use for the image.
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
color_map(str): The color map to use for the image.
color_bar(str): The type of color bar to use. Options are "simple" or "full".
@@ -324,10 +324,13 @@ class Image(ImageBase):
if monitor is None or monitor == "":
logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed")
return None
if isinstance(monitor, tuple):
if isinstance(monitor, str):
self.entry_validator.validate_monitor(monitor)
elif isinstance(monitor, Sequence):
self.entry_validator.validate_monitor(monitor[0])
else:
self.entry_validator.validate_monitor(monitor)
raise ValueError(f"Invalid monitor type: {type(monitor)}")
self.set_image_update(monitor=monitor, type=monitor_type)
if color_map is not None:
@@ -349,7 +352,7 @@ class Image(ImageBase):
if config.monitor is not None:
for combo in (self.device_combo_box, self.dim_combo_box):
combo.blockSignals(True)
if isinstance(config.monitor, tuple):
if isinstance(config.monitor, (list, tuple)):
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
else:
self.device_combo_box.setCurrentText(config.monitor)
@@ -454,7 +457,7 @@ class Image(ImageBase):
"""
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
if isinstance(monitor, tuple):
if isinstance(monitor, (list, tuple)):
device = self.dev[monitor[0]]
signal = monitor[1]
if len(monitor) == 3:
@@ -522,7 +525,7 @@ class Image(ImageBase):
Args:
monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals.
"""
if isinstance(monitor, tuple):
if isinstance(monitor, (list, tuple)):
if self.subscriptions["main"].source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1])

View File

@@ -109,6 +109,7 @@ class PlotBase(BECWidget, QWidget):
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.plot_item.visible_items = lambda: self.visible_items
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
# PlotItem Addons
@@ -895,15 +896,20 @@ class PlotBase(BECWidget, QWidget):
return
self._apply_autorange_only_visible_curves()
def _fetch_visible_curves(self):
"""
Fetch all visible curves from the plot item.
"""
visible_curves = []
for curve in self.plot_item.curves:
if curve.isVisible():
visible_curves.append(curve)
return visible_curves
@property
def visible_items(self):
crosshair_items = []
if self.crosshair:
crosshair_items = [
self.crosshair.v_line,
self.crosshair.h_line,
self.crosshair.coord_label,
]
return [
item
for item in self.plot_item.items
if item.isVisible() and item not in crosshair_items
]
def _apply_autorange_only_visible_curves(self):
"""
@@ -912,8 +918,9 @@ class PlotBase(BECWidget, QWidget):
Args:
curves (list): List of curves to apply autorange to.
"""
visible_curves = self._fetch_visible_curves()
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
visible_items = self.visible_items
self.plot_item.autoRange(items=visible_items if visible_items else None)
@SafeProperty(int, doc="The font size of the legend font.")
def legend_label_size(self) -> int:

View File

@@ -932,8 +932,17 @@ class Waveform(PlotBase):
curve = Curve(config=config, name=name, parent_item=self)
self.plot_item.addItem(curve)
self._categorise_device_curves()
curve.visibleChanged.connect(self._refresh_crosshair_markers)
curve.visibleChanged.connect(self.auto_range)
return curve
def _refresh_crosshair_markers(self):
"""
Refresh the crosshair markers when a curve visibility changes.
"""
if self.crosshair is not None:
self.crosshair.clear_markers()
def _generate_color_from_palette(self) -> str:
"""
Generate a color for the next new curve, based on the current number of curves.
@@ -1118,7 +1127,8 @@ class Waveform(PlotBase):
self.reset()
self.new_scan.emit()
self.new_scan_id.emit(current_scan_id)
self.auto_range(True)
self.auto_range_x = True
self.auto_range_y = True
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan

View File

@@ -12,8 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
class ProgressbarConnections(BaseModel):
slot: Literal["on_scan_progress", "on_device_readback"] = None
endpoint: EndpointInfo | str = None
slot: Literal["on_scan_progress", "on_device_readback", None] = None
endpoint: EndpointInfo | str | None = None
model_config: dict = {"validate_assignment": True}
@field_validator("endpoint")
@@ -222,9 +222,10 @@ class Ring(BECConnector, QObject):
device(str): Device name for the device readback mode, only used when mode is "device"
"""
if mode == "manual":
self.bec_dispatcher.disconnect_slot(
getattr(self, self.config.connections.slot), self.config.connections.endpoint
)
if self.config.connections.slot is not None:
self.bec_dispatcher.disconnect_slot(
getattr(self, self.config.connections.slot), self.config.connections.endpoint
)
self.config.connections.slot = None
self.config.connections.endpoint = None
elif mode == "scan":

View File

@@ -22,13 +22,9 @@ class RingProgressBarConfig(ConnectionConfig):
color_map: Optional[str] = Field(
"plasma", description="Color scheme for the progress bars.", validate_default=True
)
min_number_of_bars: int | None = Field(
1, description="Minimum number of progress bars to display."
)
max_number_of_bars: int | None = Field(
10, description="Maximum number of progress bars to display."
)
num_bars: int | None = Field(1, description="Number of progress bars to display.")
min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.")
max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.")
num_bars: int = Field(1, description="Number of progress bars to display.")
gap: int | None = Field(20, description="Gap between progress bars.")
auto_updates: bool | None = Field(
True, description="Enable or disable updates based on scan queue status."
@@ -245,7 +241,7 @@ class RingProgressBar(BECWidget, QWidget):
for i, ring in enumerate(self._rings):
ring.config.index = i
def set_precision(self, precision: int, bar_index: int = None):
def set_precision(self, precision: int, bar_index: int | None = None):
"""
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
@@ -274,9 +270,9 @@ class RingProgressBar(BECWidget, QWidget):
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
"""
if isinstance(min_values, int) or isinstance(min_values, float):
if isinstance(min_values, (int, float)):
min_values = [min_values]
if isinstance(max_values, int) or isinstance(max_values, float):
if isinstance(max_values, (int, float)):
max_values = [max_values]
min_values = self._adjust_list_to_bars(min_values)
max_values = self._adjust_list_to_bars(max_values)
@@ -444,14 +440,10 @@ class RingProgressBar(BECWidget, QWidget):
Returns:
Ring: Ring object.
"""
found_ring = None
for ring in self._rings:
if ring.config.index == index:
found_ring = ring
break
if found_ring is None:
raise ValueError(f"Ring with index {index} not found.")
return found_ring
return ring
raise ValueError(f"Ring with index {index} not found.")
def enable_auto_updates(self, enable: bool = True):
"""
@@ -488,29 +480,30 @@ class RingProgressBar(BECWidget, QWidget):
primary_queue = msg.get("queue").get("primary")
info = primary_queue.get("info", None)
if info:
active_request_block = info[0].get("active_request_block", None)
if active_request_block:
report_instructions = active_request_block.get("report_instructions", None)
if report_instructions:
instruction_type = list(report_instructions[0].keys())[0]
if instruction_type == "scan_progress":
self._hook_scan_progress(ring_index=0)
elif instruction_type == "readback":
devices = report_instructions[0].get("readback").get("devices")
start = report_instructions[0].get("readback").get("start")
end = report_instructions[0].get("readback").get("end")
if self.config.num_bars != len(devices):
self.set_number_of_bars(len(devices))
for index, device in enumerate(devices):
self._hook_readback(index, device, start[index], end[index])
else:
logger.error(f"{instruction_type} not supported yet.")
if not info:
return
active_request_block = info[0].get("active_request_block", None)
if not active_request_block:
return
report_instructions = active_request_block.get("report_instructions", None)
if not report_instructions:
return
# elif instruction_type == "device_progress":
# print("hook device_progress")
instruction_type = list(report_instructions[0].keys())[0]
if instruction_type == "scan_progress":
self._hook_scan_progress(ring_index=0)
elif instruction_type == "readback":
devices = report_instructions[0].get("readback").get("devices")
start = report_instructions[0].get("readback").get("start")
end = report_instructions[0].get("readback").get("end")
if self.config.num_bars != len(devices):
self.set_number_of_bars(len(devices))
for index, device in enumerate(devices):
self._hook_readback(index, device, start[index], end[index])
else:
logger.error(f"{instruction_type} not supported yet.")
def _hook_scan_progress(self, ring_index: int = None):
def _hook_scan_progress(self, ring_index: int | None = None):
"""
Hook the scan progress to the progress bars.
@@ -524,8 +517,7 @@ class RingProgressBar(BECWidget, QWidget):
if ring.config.connections.slot == "on_scan_progress":
return
else:
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
"""
@@ -579,6 +571,8 @@ class RingProgressBar(BECWidget, QWidget):
return bar_index
def paintEvent(self, event):
if not self._rings:
return
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
size = min(self.width(), self.height())
@@ -631,9 +625,8 @@ class RingProgressBar(BECWidget, QWidget):
return QSize(10, 10)
ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)]
total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1)
diameter = total_width * 2
if diameter < 50:
diameter = 50
diameter = max(total_width * 2, 50)
return QSize(diameter, diameter)
def sizeHint(self):

View File

@@ -1,6 +1,4 @@
import os
import re
from functools import partial
from typing import Callable
import bec_lib
@@ -11,23 +9,17 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_qthemes import material_icon
from pyqtgraph import SignalProxy
from qtpy.QtCore import QSize, QThreadPool, Signal
from qtpy.QtWidgets import (
QFileDialog,
QListWidget,
QListWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from qtpy.QtCore import QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
@@ -59,7 +51,8 @@ class DeviceBrowser(BECWidget, QWidget):
self._q_threadpool = QThreadPool()
self.ui = None
self.init_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list = ListOfExpandableFrames(self, DeviceItem)
self.ui.verticalLayout.addWidget(self.dev_list)
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
@@ -114,7 +107,7 @@ class DeviceBrowser(BECWidget, QWidget):
)
def _create_add_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add")
dialog.open()
def on_device_update(self, action: ConfigAction, content: dict) -> None:
@@ -132,25 +125,15 @@ class DeviceBrowser(BECWidget, QWidget):
def init_device_list(self):
self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
with RPCRegister.delayed_broadcast():
for device, device_obj in self.dev.items():
self._add_item_to_list(device, device_obj)
def _add_item_to_list(self, device: str, device_obj):
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
device_item.adjustSize()
item.setSizeHint(QSize(device_item.width(), device_item.height()))
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
def _remove_item(item: QListWidgetItem):
self.dev_list.takeItem(self.dev_list.row(item))
del self._device_items[device]
self.dev_list.sortItems()
item = QListWidgetItem(self.dev_list)
device_item = DeviceItem(
_, device_item = self.dev_list.add_item(
id=device,
parent=self,
device=device,
devices=self.dev,
@@ -158,18 +141,11 @@ class DeviceBrowser(BECWidget, QWidget):
config_helper=self._config_helper,
q_threadpool=self._q_threadpool,
)
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
device_item.imminent_deletion.connect(partial(_remove_item, item))
self.editing_enabled.connect(device_item.set_editable)
self.device_update.connect(device_item.config_update)
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot(dict, dict)
def scan_status_changed(self, scan_info: dict, _: dict):
@@ -198,20 +174,11 @@ class DeviceBrowser(BECWidget, QWidget):
Either way, the function will filter the devices based on the filter input text and update the device list.
"""
filter_text = self.ui.filter_input.text()
for device in self.dev:
if device not in self._device_items:
if device not in self.dev_list:
# it is possible the device has just been added to the config
self._add_item_to_list(device, self.dev[device])
try:
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
self.regex = None # Invalid regex, disable filtering
for device in self.dev:
self._device_items[device].setHidden(False)
return
for device in self.dev:
self._device_items[device].setHidden(not self.regex.search(device))
self.dev_list.update_filter(self.ui.filter_input.text())
@SafeSlot()
def _load_from_file(self):

View File

@@ -1,93 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>406</width>
<height>500</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="browser_group_box">
<property name="title">
<string>Device Browser</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="filter_layout">
<item>
<widget class="QLineEdit" name="filter_input">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="button_box">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="add_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="save_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="import_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="scan_running_warning">
<property name="styleSheet">
<string notr="true"/>
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>406</width>
<height>500</height>
</rect>
</property>
<property name="text">
<string>warning</string>
<property name="windowTitle">
<string>Form</string>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="device_list"/>
</item>
</layout>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="browser_group_box">
<property name="title">
<string>Device Browser</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="filter_layout">
<item>
<widget class="QLineEdit" name="filter_input">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="button_box">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="add_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="save_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="import_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="scan_running_warning">
<property name="styleSheet">
<string notr="true" />
</property>
<property name="text">
<string>warning</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
<resources />
<connections />
</ui>

View File

@@ -34,7 +34,11 @@ class CommunicateConfigAction(QRunnable):
@SafeSlot()
def run(self):
try:
if self.action in ["add", "update", "remove"]:
if self.action == "set":
self._process(
{"action": self.action, "config": self.config, "wait_for_response": False}
)
elif self.action in ["add", "update", "remove"]:
if (dev_name := self.device or self.config.get("name")) is None:
raise ValueError(
"Must be updating a device or be supplied a name for a new device"
@@ -57,6 +61,9 @@ class CommunicateConfigAction(QRunnable):
"config": {dev_name: self.config},
"wait_for_response": False,
}
self._process(req_args)
def _process(self, req_args: dict):
timeout = (
self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20
)

View File

@@ -5,12 +5,14 @@ from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from pydantic import ValidationError, field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from pydantic import BaseModel, field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QStackedLayout,
QVBoxLayout,
@@ -19,6 +21,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
@@ -29,6 +32,8 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
_StdBtn = QDialogButtonBox.StandardButton
def _try_literal_eval(value: str):
if value == "":
@@ -39,79 +44,36 @@ def _try_literal_eval(value: str):
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
class DeviceConfigDialog(BECWidget, QDialog):
class DeviceConfigDialog(QDialog):
RPC = False
applied = Signal()
accepted_data = Signal(dict)
def __init__(
self,
*,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
action: Literal["update", "add"] = "update",
threadpool: QThreadPool | None = None,
**kwargs,
self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs
):
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
for device specification in bec_lib.atlas_models.
Args:
parent (QObject): the parent QObject
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
"""
self._initial_config = {}
self._class_deviceconfig_item = class_deviceconfig_item
super().__init__(parent=parent, **kwargs)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
)
self._device = device
self._action: Literal["update", "add"] = action
self._q_threadpool = threadpool or QThreadPool()
self.setWindowTitle(f"Edit config for: {device}")
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackAll)
self._container.setStackingMode(QStackedLayout.StackingMode.StackAll)
self._layout = QVBoxLayout()
user_warning = QLabel(
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
)
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.addWidget(user_warning)
self.get_bec_shortcuts()
self._data = {}
self._add_form()
if self._action == "update":
self._form._validity.setVisible(False)
else:
self._set_schema_to_check_devices()
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
# self._form._validity.setVisible(True)
self._form.validity_proc.connect(self.enable_buttons_for_validity)
self._add_overlay()
self._add_buttons()
self.setWindowTitle("Add new device")
self.setLayout(self._container)
self._form.validate_form()
self._overlay_widget.setVisible(False)
self._form._validity.setVisible(True)
self._connect_form()
def _set_schema_to_check_devices(self):
class _NameValidatedConfigModel(DeviceConfigModel):
@field_validator("name")
@staticmethod
def _validate_name(value: str, *_):
if not value.isidentifier():
raise ValueError(
f"Invalid device name: {value}. Device names must be valid Python identifiers."
)
if value in self.dev:
raise ValueError(f"A device with name {value} already exists!")
return value
self._form.set_schema(_NameValidatedConfigModel)
def _connect_form(self):
self._form.validity_proc.connect(self.enable_buttons_for_validity)
self._form.validate_form()
def _add_form(self):
self._form_widget = QWidget()
@@ -119,16 +81,6 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
for row in self._form.enumerate_form_widgets():
if (
row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE
and self._action == "update"
):
row.widget._set_pretty_display()
if self._action == "update" and self._device in self.dev:
self._fetch_config()
self._fill_form()
self._container.addWidget(self._form_widget)
def _add_overlay(self):
@@ -145,21 +97,12 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
self.button_box = QDialogButtonBox(
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel)
self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self._layout.addWidget(self.button_box)
def _fetch_config(self):
if (
self.client.device_manager is not None
and self._device in self.client.device_manager.devices
):
self._initial_config = self.client.device_manager.devices.get(self._device)._config
def _fill_form(self):
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
@@ -190,12 +133,16 @@ class DeviceConfigDialog(BECWidget, QDialog):
@SafeSlot(bool)
def enable_buttons_for_validity(self, valid: bool):
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
for button in [
self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok]
]:
for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]:
button.setEnabled(valid)
button.setToolTip(self._form._validity_message.text())
def _process_action(self):
self.accepted_data.emit(self._form.get_form_data())
def get_data(self):
return self._data
@SafeSlot(popup_error=True)
def apply(self):
self._process_action()
@@ -206,10 +153,138 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._process_action()
return super().accept()
class EpicsMotorConfig(BaseModel):
prefix: str
class EpicsSignalROConfig(BaseModel):
read_pv: str
class EpicsSignalConfig(BaseModel):
read_pv: str
write_pv: str | None = None
class PresetClassDeviceConfigDialog(DeviceConfigDialog):
def __init__(self, *, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._device_models = {
"EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}),
"EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}),
"EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}),
"Custom": (None, {}),
}
self._create_selection_box()
self._selection_box.currentTextChanged.connect(self._replace_form)
def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]):
for field_name, (value, editable) in constraints.items():
if (widget := self._form.widget_dict.get(field_name)) is not None:
widget.setValue(value)
if not editable:
widget._set_pretty_display()
def _replace_form(self, deviceconfig_cls_key):
self._form.deleteLater()
if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None:
devmodel, params = devmodel_params
else:
devmodel, params = None, {}
self._form = DeviceConfigForm(class_deviceconfig_item=devmodel)
self._apply_constraints(params)
self._layout.insertWidget(1, self._form)
self._connect_form()
def _create_selection_box(self):
layout = QHBoxLayout()
self._selection_box = QComboBox()
self._selection_box.addItems(list(self._device_models.keys()))
layout.addWidget(QLabel("Choose a device class: "))
layout.addWidget(self._selection_box)
self._layout.insertLayout(0, layout)
class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog):
def __init__(
self,
*,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
action: Literal["update"] | Literal["add"] = "update",
threadpool: QThreadPool | None = None,
**kwargs,
):
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
for device specification in bec_lib.atlas_models.
Args:
parent (QObject): the parent QObject
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
"""
self._device = device
self._q_threadpool = threadpool or QThreadPool()
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
)
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
self._action: Literal["update", "add"] = action
user_warning = QLabel(
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
)
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.insertWidget(0, user_warning)
self.setWindowTitle(
f"Edit config for: {device}" if action == "update" else "Add new device"
)
if self._action == "update":
self._modify_for_update()
self._form.validity_proc.disconnect(self.enable_buttons_for_validity)
else:
self._set_schema_to_check_devices()
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
# self._form._validity.setVisible(True)
def _modify_for_update(self):
for row in self._form.enumerate_form_widgets():
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
row.widget._set_pretty_display()
if self._device in self.dev:
self._fetch_config()
self._fill_form()
self._form._validity.setVisible(False)
def _set_schema_to_check_devices(self):
class _NameValidatedConfigModel(DeviceConfigModel):
@field_validator("name")
@staticmethod
def _validate_name(value: str, *_):
if not value.isidentifier():
raise ValueError(
f"Invalid device name: {value}. Device names must be valid Python identifiers."
)
if value in self.dev:
raise ValueError(f"A device with name {value} already exists!")
return value
self._form.set_schema(_NameValidatedConfigModel)
def _fetch_config(self):
if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore
self._initial_config = device._config
def _process_action(self):
updated_config = self.updated_config()
if self._action == "add":
if (name := updated_config.get("name")) in self.dev:
if self.dev is not None and (name := updated_config.get("name")) in self.dev:
raise ValueError(
f"Can't create a new device with the same name as already existing device {name}!"
)
@@ -249,12 +324,12 @@ class DeviceConfigDialog(BECWidget, QDialog):
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QApplication.processEvents()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QApplication.processEvents()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def main(): # pragma: no cover
@@ -269,10 +344,10 @@ def main(): # pragma: no cover
app = QApplication(sys.argv)
apply_theme("light")
widget = QWidget()
widget.setLayout(QVBoxLayout())
widget.setLayout(layout := QVBoxLayout())
device = QLineEdit()
widget.layout().addWidget(device)
layout.addWidget(device)
def _destroy_dialog(*_):
nonlocal dialog
@@ -285,14 +360,14 @@ def main(): # pragma: no cover
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = DeviceConfigDialog(**kwargs)
kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()
button = QPushButton("Show device dialog")
widget.layout().addWidget(button)
layout.addWidget(button)
button.clicked.connect(_show_dialog)
widget.show()
sys.exit(app.exec_())

View File

@@ -1,16 +1,20 @@
from __future__ import annotations
from functools import partial
from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import get_theme_name
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem
from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolFormItem,
BoolToggleFormItem,
DictFormItem,
FormItemSpec,
)
@@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm):
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
def __init__(
self,
parent=None,
client=None,
pretty_display=False,
class_deviceconfig_item: type[BaseModel] | None = None,
**kwargs,
):
super().__init__(
parent=parent,
data_model=DeviceConfigModel,
@@ -26,18 +37,28 @@ class DeviceConfigForm(PydanticModelForm):
client=client,
**kwargs,
)
self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
self._widget_types["optional_bool"] = (
lambda spec: spec.item_type == bool | None,
BoolFormItem,
)
self._validity.setVisible(False)
pred, _ = self._widget_types["dict"]
self._widget_types["dict"] = pred, self._custom_device_config_item
self._validity.setVisible(True)
self._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def _custom_device_config_item(self, spec: FormItemSpec):
if spec.name != "deviceConfig":
return DictFormItem
if self._class_deviceconfig_item is not None:
return partial(PydanticModelFormItem, model=self._class_deviceconfig_item)
return DictFormItem
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
theme = get_theme_name()

View File

@@ -18,7 +18,7 @@ from bec_widgets.widgets.services.device_browser.device_item.config_communicator
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
@@ -35,9 +35,6 @@ logger = bec_logger.logger
class DeviceItem(ExpandableGroupFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
RPC = False
def __init__(
@@ -94,7 +91,7 @@ class DeviceItem(ExpandableGroupFrame):
@SafeSlot()
def _create_edit_dialog(self):
dialog = DeviceConfigDialog(
dialog = DirectUpdateDeviceConfigDialog(
parent=self,
device=self.device,
config_helper=self._config_helper,

View File

@@ -1,13 +1,18 @@
import datetime
import importlib
import importlib.metadata
import os
import re
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
from bec_widgets.widgets.containers.explorer.explorer import Explorer
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
@@ -17,16 +22,19 @@ class IDEExplorer(BECWidget, QWidget):
PLUGIN = True
RPC = False
file_open_requested = Signal(str, str)
file_preview_requested = Signal(str, str)
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._sections = set()
self._sections = [] # Use list to maintain order instead of set
self.main_explorer = Explorer(parent=self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.main_explorer)
self.setLayout(layout)
self.sections = ["scripts"]
self.sections = ["scripts", "macros"]
@SafeProperty(list)
def sections(self):
@@ -35,10 +43,16 @@ class IDEExplorer(BECWidget, QWidget):
@sections.setter
def sections(self, value):
existing_sections = set(self._sections)
self._sections = set(value)
self._update_section_visibility(self._sections - existing_sections)
new_sections = set(value)
# Find sections to add, maintaining the order from the input value list
sections_to_add = [
section for section in value if section in (new_sections - existing_sections)
]
self._sections = list(value) # Store as ordered list
self._update_section_visibility(sections_to_add)
def _update_section_visibility(self, sections):
# sections is now an ordered list, not a set
for section in sections:
self._add_section(section)
@@ -46,15 +60,18 @@ class IDEExplorer(BECWidget, QWidget):
match section_name.lower():
case "scripts":
self.add_script_section()
case "macros":
self.add_macro_section()
case _:
pass
def add_script_section(self):
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
section.expanded = False
script_explorer = Explorer(parent=self)
script_widget = ScriptTreeWidget(parent=self)
script_widget.file_open_requested.connect(self._emit_file_open_scripts_local)
script_widget.file_selected.connect(self._emit_file_preview_scripts_local)
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
local_scripts_section.set_widget(script_widget)
@@ -77,15 +94,89 @@ class IDEExplorer(BECWidget, QWidget):
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
return
shared_script_section = CollapsibleSection(title="Shared", parent=self)
shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
shared_script_section.setToolTip("Shared scripts (read-only)")
shared_script_widget = ScriptTreeWidget(parent=self)
shared_script_section.set_widget(shared_script_widget)
shared_script_widget.set_directory(plugin_scripts_dir)
script_explorer.add_section(shared_script_section)
shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared)
shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared)
# macros_section = CollapsibleSection("MACROS", indentation=0)
# macros_section.set_widget(QLabel("Macros will be implemented later"))
# self.main_explorer.add_section(macros_section)
def add_macro_section(self):
section = CollapsibleSection(
parent=self,
title="MACROS",
indentation=0,
show_add_button=True,
tooltip="Macros are reusable functions that can be called from scripts or the console.",
)
section.header_add_button.setIcon(material_icon("refresh", size=(20, 20)))
section.header_add_button.setToolTip("Reload all macros")
section.header_add_button.clicked.connect(self._reload_macros)
macro_explorer = Explorer(parent=self)
macro_widget = MacroTreeWidget(parent=self)
macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local)
macro_widget.macro_selected.connect(self._emit_file_preview_macros_local)
local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_macros_section.header_add_button.clicked.connect(self._add_local_macro)
local_macros_section.set_widget(macro_widget)
local_macro_dir = self.client._service_config.model.user_macros.base_path
if not os.path.exists(local_macro_dir):
os.makedirs(local_macro_dir)
macro_widget.set_directory(local_macro_dir)
macro_explorer.add_section(local_macros_section)
section.set_widget(macro_explorer)
self.main_explorer.add_section(section)
plugin_macros_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_macros_dir = os.path.join(plugin.__path__[0], "macros")
break
if not plugin_macros_dir or not os.path.exists(plugin_macros_dir):
return
shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
shared_macro_section.setToolTip("Shared macros (read-only)")
shared_macro_widget = MacroTreeWidget(parent=self)
shared_macro_section.set_widget(shared_macro_widget)
shared_macro_widget.set_directory(plugin_macros_dir)
macro_explorer.add_section(shared_macro_section)
shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared)
shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared)
def _emit_file_open_scripts_local(self, file_name: str):
self.file_open_requested.emit(file_name, "scripts/local")
def _emit_file_preview_scripts_local(self, file_name: str):
self.file_preview_requested.emit(file_name, "scripts/local")
def _emit_file_open_scripts_shared(self, file_name: str):
self.file_open_requested.emit(file_name, "scripts/shared")
def _emit_file_preview_scripts_shared(self, file_name: str):
self.file_preview_requested.emit(file_name, "scripts/shared")
def _emit_file_open_macros_local(self, function_name: str, file_path: str):
self.file_open_requested.emit(file_path, "macros/local")
def _emit_file_preview_macros_local(self, function_name: str, file_path: str):
self.file_preview_requested.emit(file_path, "macros/local")
def _emit_file_open_macros_shared(self, function_name: str, file_path: str):
self.file_open_requested.emit(file_path, "macros/shared")
def _emit_file_preview_macros_shared(self, function_name: str, file_path: str):
self.file_preview_requested.emit(file_path, "macros/shared")
def _add_local_script(self):
"""Show a dialog to enter the name of a new script and create it."""
@@ -136,6 +227,134 @@ class IDEExplorer(BECWidget, QWidget):
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
def _add_local_macro(self):
"""Show a dialog to enter the name of a new macro function and create it."""
target_section = self.main_explorer.get_section("MACROS")
macro_dir_section = target_section.content_widget.get_section("Local")
local_macro_dir = macro_dir_section.content_widget.directory
# Prompt user for function name
function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:")
if not ok or not function_name:
return # User cancelled or didn't enter a name
# Sanitize function name
function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name)
if not function_name or function_name[0].isdigit():
QMessageBox.warning(
self, "Invalid Name", "Function name must be a valid Python identifier."
)
return
# Create filename based on function name
filename = f"{function_name}.py"
file_path = os.path.join(local_macro_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
response = QMessageBox.question(
self,
"File exists",
f"The file '{filename}' already exists. Do you want to overwrite it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if response != QMessageBox.StandardButton.Yes:
return # User chose not to overwrite
try:
# Create the file with a macro function template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f'''"""
{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
"""
def {function_name}():
"""
Description of what this macro does.
Add your macro implementation here.
"""
print(f"Executing macro: {function_name}")
# TODO: Add your macro code here
pass
'''
)
# Refresh the macro tree to show the new function
macro_dir_section.content_widget.refresh()
except Exception as e:
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}")
def _reload_macros(self):
"""Reload all macros using the BEC client."""
try:
if hasattr(self.client, "macros"):
self.client.macros.load_all_user_macros()
# Refresh the macro tree widgets to show updated functions
target_section = self.main_explorer.get_section("MACROS")
if target_section and hasattr(target_section, "content_widget"):
local_section = target_section.content_widget.get_section("Local")
if local_section and hasattr(local_section, "content_widget"):
local_section.content_widget.refresh()
shared_section = target_section.content_widget.get_section("Shared")
if shared_section and hasattr(shared_section, "content_widget"):
shared_section.content_widget.refresh()
QMessageBox.information(
self, "Reload Macros", "Macros have been reloaded successfully."
)
else:
QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}")
def refresh_macro_file(self, file_path: str):
"""Refresh a single macro file in the tree widget.
Args:
file_path: Path to the macro file that was updated
"""
target_section = self.main_explorer.get_section("MACROS")
if not target_section or not hasattr(target_section, "content_widget"):
return
# Determine if this is a local or shared macro based on the file path
local_section = target_section.content_widget.get_section("Local")
shared_section = target_section.content_widget.get_section("Shared")
# Check if file belongs to local macros directory
if (
local_section
and hasattr(local_section, "content_widget")
and hasattr(local_section.content_widget, "directory")
):
local_macro_dir = local_section.content_widget.directory
if local_macro_dir and file_path.startswith(local_macro_dir):
local_section.content_widget.refresh_file_item(file_path)
return
# Check if file belongs to shared macros directory
if (
shared_section
and hasattr(shared_section, "content_widget")
and hasattr(shared_section.content_widget, "directory")
):
shared_macro_dir = shared_section.content_widget.directory
if shared_macro_dir and file_path.startswith(shared_macro_dir):
shared_section.content_widget.refresh_file_item(file_path)
return
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication

View File

@@ -8,7 +8,6 @@ import numpy as np
from bec_lib.device import Device, Signal
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as QSignal
from qtpy.QtWidgets import (
QApplication,
@@ -481,6 +480,11 @@ class SignalLabel(BECWidget, QWidget):
self._custom_label if self._custom_label else f"{self._default_label}:"
)
def cleanup(self):
self.disconnect_device()
self._device_obj = None
super().cleanup()
if __name__ == "__main__":
app = QApplication(sys.argv)

View File

@@ -1,12 +1,11 @@
(api_reference)=
# API Reference
```{eval-rst}
.. autosummary::
:toctree: _autosummary
:template: custom-module-template.rst
:recursive:
This page contains the auto-generated API documentation for all modules, classes, and functions in the BEC Widgets package.
bec_widgets
```{toctree}
:maxdepth: 2
:caption: API Documentation
../autoapi/bec_widgets/index
```

View File

@@ -32,16 +32,15 @@ def get_version():
release = get_version()
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
# "sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx_toolbox.collapse",
"sphinx_copybutton",
"myst_parser",
"sphinx_design",
"sphinx_inline_tabs",
"autoapi.extension",
"sphinx.ext.viewcode",
]
myst_enable_extensions = [
@@ -60,7 +59,15 @@ myst_enable_extensions = [
"tasklist",
]
autosummary_generate = True # Turn on sphinx.ext.autosummary
# AutoAPI configuration
autoapi_dirs = ["../bec_widgets"]
autoapi_type = "python"
autoapi_generate_api_docs = True
autoapi_add_toctree_entry = False # We'll control the toctree manually
autoapi_keep_files = False
autoapi_python_class_content = "both" # Include both class docstring and __init__
autoapi_member_order = "groupwise"
add_module_names = False # Remove namespaces from class/method signatures
autodoc_inherit_docstrings = True # If no docstring, inherit from base class
set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints
@@ -80,3 +87,30 @@ html_theme = "pydata_sphinx_theme"
html_static_path = ["_static"]
html_css_files = ["custom.css"]
html_logo = "../bec_widgets/assets/app_icons/bec_widgets_icon.png"
def skip_submodules(app, what, name, obj, skip, options):
if what == "module":
if not name.startswith("bec_widgets"):
skip = True
# print(f"Checking module: {name}")
if "bec_widgets.widgets" in name:
widget = name.split(".")[-2]
submodule = name.split(".")[-1]
if submodule in [f"register_{widget}", f"{widget}_plugin"]:
# print(f"Skipping submodule: {name}")
skip = True
elif what in ["data", "attribute"]:
obj_name = name.split(".")[-1]
if obj_name.startswith("_") or obj_name in ["__all__", "logger", "bec_logger", "app"]:
skip = True
elif what == "class":
class_name = name.split(".")[-1]
if class_name.startswith("Demo"):
skip = True
return skip
def setup(app):
app.connect("autoapi-skip-member", skip_submodules)

View File

@@ -10,5 +10,5 @@ We offer up to three different options for composing larger GUIs from these modu
## Client-Server Architecture
BEC Widgets is built on top of the [BEC](https://bec.readthedocs.io/en/latest/) package, which provides the backend for beamline experiment control. BEC Widgets is a client of BEC, meaning it can interact with the backend through a client-server architecture. To make full usage of the available features of BEC, we recommend to check the documentation about [data access](https://bec.readthedocs.io/en/latest/developer/data_access/data_access.html) in which the messaging and event system of BEC is described.
In the context of BEC Widgets, the [`BECDispatcher`](/api_reference/_autosummary/bec_widgets.utils.bec_dispatcher.BECDispatcher) connects to this messaging and event system, allowing you to link your Qt [`Slots`](https://www.pythonguis.com/tutorials/pyside6-signals-slots-events/) to messages and event received from BEC.
In the context of BEC Widgets, the {py:class}`~bec_widgets.utils.bec_dispatcher.BECDispatcher` connects to this messaging and event system, allowing you to link your Qt [`Slots`](https://www.pythonguis.com/tutorials/pyside6-signals-slots-events/) to messages and event received from BEC.

View File

@@ -8,7 +8,7 @@ Therefore, we recommend that you install BEC first following the [developer inst
If you already have a BEC environment set up, you can install BEC Widgets in editable mode into your BEC Python environment.
**Prerequisites**
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
1. **Python Version:** BEC Widgets requires Python version 3.11 or higher. Verify your Python version to ensure compatibility.
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
3. **Qt Distributions:** BEC Widgets supports [PySide6](https://doc.qt.io/qtforpython-6/quickstart.html) and [PyQt6](https://www.riverbankcomputing.com/static/Docs/PyQt6/introduction.html). We use [qtpy](https://pypi.org/project/QtPy/) to abstract the underlying QT distribution.

View File

@@ -7,4 +7,5 @@ sphinx-copybutton
sphinx-inline-tabs
myst-parser
sphinx-design
sphinx-autoapi
tomli

View File

@@ -1,11 +1,10 @@
(user.api_reference)=
# User API Reference
```{eval-rst}
.. autosummary::
:toctree: _autosummary
:template: custom-module-template.rst
This section contains the API documentation for the main user-facing modules and classes.
bec_widgets.cli.client
```{toctree}
:maxdepth: 2
../../autoapi/bec_widgets/cli/index
```

View File

@@ -4,7 +4,7 @@
Before installing BEC Widgets, please ensure the following requirements are met:
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
1. **Python Version:** BEC Widgets requires Python version 3.11 or higher. Verify your Python version to ensure compatibility.
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
**Standard Installation**

View File

@@ -3,9 +3,9 @@
In order to use BEC Widgets as a plotting tool for BEC, it needs to be [installed](#user.installation) in the same Python environment as the BEC IPython client (please refer to the [BEC documentation](https://bec.readthedocs.io/en/latest/user/command_line_interface.html#start-up) for more details). Upon startup, the client will automatically launch a GUI and store it as a `gui` object in the client. The GUI backend will also be automatically connect to the BEC server, giving access to all information on the server and allowing the user to visualize the data in real-time.
## BECGuiClient
The `gui` object is the main entry point for interacting with the BEC Widgets framework. It is an instance of the [`BECGuiClient`](/api_reference/_autosummary/bec_widgets.cli.client.BECGuiClient) class, which provides methods to create and manage GUI components. Upon BEC startup, a default [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) instance named *bec* is automatically launched.
The `gui` object is the main entry point for interacting with the BEC Widgets framework. It is an instance of the {py:class}`~bec_widgets.cli.client_utils.BECGuiClient` class, which provides methods to create and manage GUI components. Upon BEC startup, a default {py:class}`~bec_widgets.cli.client.BECDockArea` instance named *bec* is automatically launched.
A launcher interface is available via the top menu bar under New → Open Launcher. This opens a window where users can launch a new [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) instance, an [AutoUpdate](#user.auto_updates) instance, individual widgets or a custom *ui file* created with *BEC Designer*. Alternatively, users can launch a new [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) from the command line:
A launcher interface is available via the top menu bar under New → Open Launcher. This opens a window where users can launch a new {py:class}`~bec_widgets.cli.client.BECDockArea` instance, an [AutoUpdate](#user.auto_updates) instance, individual widgets or a custom *ui file* created with *BEC Designer*. Alternatively, users can launch a new {py:class}`~bec_widgets.cli.client.BECDockArea` from the command line:
```python
dock_area = gui.new() # launches a new BECDockArea instance
@@ -19,7 +19,7 @@ If a name is provided, the new dock area will use that name. If the name already
## BECDockArea
The [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) is a versatile container for quickly building customized GUIs. It supports adding new widgets either through the CLI or directly via toolbar actions. Widgets must be added into [`BECDock`](/api_reference/_autosummary/bec_widgets.cli.client.BECDock) instances, which serve as the individual containers. These docks can be arranged freely, detached from the main window, and used as floating panels.
The {py:class}`~bec_widgets.cli.client.BECDockArea` is a versatile container for quickly building customized GUIs. It supports adding new widgets either through the CLI or directly via toolbar actions. Widgets must be added into {py:class}`~bec_widgets.cli.client.BECDockArea` instances, which serve as the individual containers. These docks can be arranged freely, detached from the main window, and used as floating panels.
From the CLI, you can create new docks like this:
@@ -34,23 +34,23 @@ dock = gui.new().new()
![BECDockArea.png](BECDockArea.png) -->
## Widgets
Widgets are the building blocks of the BEC Widgets framework. They are the visual components that allow users to interact with the data and control the behavior of the application. Each dock can contain multiple widgets, albeit we recommend for most use cases a single widget per dock. BEC Widgets provides a set of core widgets (cf. [widgets](#user.widgets)). More widgets can be added by the users, and we invite you to explore the [developer documentation](developer.widgets) to learn how to create custom widgets.
Widgets are the building blocks of the BEC Widgets framework. They are the visual components that allow users to interact with the data and control the behavior of the application. Each dock can contain multiple widgets, albeit we recommend for most use cases a single widget per dock. BEC Widgets provides a set of core widgets (cf. {ref}`user.widgets`). More widgets can be added by the users, and we invite you to explore the {ref}`developer.widgets` to learn how to create custom widgets.
For the introduction given here, we will focus on the plotting widgets of BECWidgets.
<!-- We also provide two methods [`plot()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.plot), [`image()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.image) and [`motor_map()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.motor_map) as shortcuts to add a plot, image or motor map to the BECFigure. -->
**Waveform Plot**
The [`WaveForm`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm) is a widget that can be used to visualize 1D waveform data, i.e. to plot data of a monitor against a motor position. The method [`plot()`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm.rst#bec_widgets.cli.client.WaveForm.plot) returns the plot object.
The {py:class}`~bec_widgets.cli.client.Waveform` is a widget that can be used to visualize 1D waveform data, i.e. to plot data of a monitor against a motor position. The method {py:meth}`~bec_widgets.cli.client.Waveform.plot` returns the plot object.
```python
plt = gui.new().new().new(gui.available_widgets.Waveform)
plt.plot(x_name='samx', y_name='bpm4i')
```
Here, we create a new plot with a subscription to the devices `samx` and `bpm4i` and assign the plot to the object `plt`. We can now use this object to further customize the plot, e.g. changing the title ([`plt.title = 'my title' `](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.title)), axis labels ([`plt.x_label = 'my x label'`](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.x_label))
<!-- or limits ([`set_x_lim()`](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.x_lim)). -->
Here, we create a new plot with a subscription to the devices `samx` and `bpm4i` and assign the plot to the object `plt`. We can now use this object to further customize the plot, e.g. changing the title (`title`), axis labels (`x_label`)
<!-- or limits (`x_lim`). -->
We invite you to explore the API of the WaveForm in the [documentation](user.widgets.waveform_1d) or directly in the command line.
We invite you to explore the API of the WaveForm in the {ref}`user.widgets.waveform_1d` or directly in the command line.
To plot custom data, i.e. data that is not directly available through a scan in BEC, we can use the same method, but provide the data directly to the plot.
@@ -68,18 +68,18 @@ curve = plt.plot(x=[1,2,3,4], y=[1,4,9,16])
**Scatter Plot**
The [`WaveForm`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm) widget can also be used to visualize 2D scatter plots. More details on setting up the scatter plot are available in the widget documentation of the [scatter plot](user.widgets.scatter_2d).
The {py:class}`~bec_widgets.cli.client.Waveform` widget can also be used to visualize 2D scatter plots. More details on setting up the scatter plot are available in the widget documentation of the {ref}`user.widgets.scatter_2d`.
**Motor Map**
The [`MotorMap`](/api_reference/_autosummary/bec_widgets.cli.client.MotorMap) widget can be used to visualize the position of motors. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans. More details on setting up the motor map are available in the widget documentation of the [motor map](user.widgets.motor_map).
The {py:class}`~bec_widgets.cli.client.MotorMap` widget can be used to visualize the position of motors. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans. More details on setting up the motor map are available in the widget documentation of the {ref}`user.widgets.motor_map`.
**Image Plot**
The [`Image`](/api_reference/_autosummary/bec_widgets.cli.client.Image) widget can be used to visualize 2D image data for example a camera. More details on setting up the image plot are available in the widget documentation of the [image plot](user.widgets.image).
The {py:class}`~bec_widgets.cli.client.Image` widget can be used to visualize 2D image data for example a camera. More details on setting up the image plot are available in the widget documentation of the {ref}`user.widgets.image`.
### Useful Commands
We recommend users to explore the API of the widgets by themselves since we assume that the user interface is supposed to be intuitive and self-explanatory. We appreciate feedback from user in order to constantly improve the experience and allow easy access to the gui, widgets and their functionality. We recommend checking the [API documentation](user.api_reference), but also by using BEC Widgets, exploring the available functions and check their dockstrings.
We recommend users to explore the API of the widgets by themselves since we assume that the user interface is supposed to be intuitive and self-explanatory. We appreciate feedback from user in order to constantly improve the experience and allow easy access to the gui, widgets and their functionality. We recommend checking the {ref}`user.api_reference`, but also by using BEC Widgets, exploring the available functions and check their dockstrings.
```python
gui.new? # shows the dockstring of the new method
```

View File

@@ -3,7 +3,7 @@
```{tab} Overview
The [`BECProgressbar`](/api_reference/_autosummary/bec_widgets.cli.client.BECProgressBar) widget is a general purpose progress bar that follows the BEC theme and style. It can be embedded in any application to display the progress of a task or operation.
The {py:class}`~bec_widgets.cli.client.BECProgressBar` widget is a general purpose progress bar that follows the BEC theme and style. It can be embedded in any application to display the progress of a task or operation.
## Key Features:
- **Modern Design**: The BEC Progressbar widget is designed with a modern and sleek appearance, following the BEC theme.
@@ -35,6 +35,8 @@ pb.set_value(50)
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECProgressBar.rst
.. autoclass:: bec_widgets.cli.client.BECProgressBar
:members:
:show-inheritance:
```
````

View File

@@ -3,7 +3,7 @@
````{tab} Overview
The [`BEC Status Box`](/api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox) widget is designed to monitor the status and health of all running BEC processes. This widget provides a real-time overview of the BEC core services, including DeviceServer, ScanServer, SciHub, ScanBundler, and FileWriter. The top-level display indicates the overall state of the BEC services, while the collapsed view allows users to delve into the status of each individual process. By double-clicking on a specific process, users can access a detailed popup window with live updates of the metrics for that process.
The {py:class}`~bec_widgets.cli.client.BECStatusBox` widget is designed to monitor the status and health of all running BEC processes. This widget provides a real-time overview of the BEC core services, including DeviceServer, ScanServer, SciHub, ScanBundler, and FileWriter. The top-level display indicates the overall state of the BEC services, while the collapsed view allows users to delve into the status of each individual process. By double-clicking on a specific process, users can access a detailed popup window with live updates of the metrics for that process.
## Key Features:
- **Comprehensive Service Monitoring**: Track the state of individual BEC services, including real-time updates on their health and status.
@@ -33,6 +33,8 @@ Once the `BECStatusBox` is added, users can interact with it to view the status
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox.rst
.. autoclass:: bec_widgets.cli.client.BECStatusBox
:members:
:show-inheritance:
```
````

View File

@@ -146,8 +146,14 @@ my_gui.show()
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DarkModeButton.rst
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ColorButton.rst
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ColormapSelector.rst
.. autoclass:: bec_widgets.cli.client.DarkModeButton
:members:
:show-inheritance:
.. autoclass:: bec_widgets.cli.client.ColorButton
:members:
:show-inheritance:
.. autoclass:: bec_widgets.cli.client.ColormapSelector
:members:
:show-inheritance:
```
````

View File

@@ -39,7 +39,7 @@ The `Reset Button` is used to reset the scan queue. It prompts the user for conf
- **Toolbar and Button Options**: Can be configured as a toolbar button or a standard push button.
```
`````{tab} Examples
````{tab} Examples
Integrating these buttons into a BEC GUI layout is straightforward. The following examples demonstrate how to embed these buttons within a custom GUI layout using `QtWidgets`.
@@ -66,12 +66,21 @@ app.exec_()
```
`ResumeButton`, `ResetButton`, and `AbortButton` may be used in an exactly analogous way.
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.StopButton.rst
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ResumeButton.rst
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.AbortButton.rst
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ResetButton.rst
.. autoclass:: bec_widgets.cli.client.StopButton
:members:
:show-inheritance:
.. autoclass:: bec_widgets.cli.client.ResumeButton
:members:
:show-inheritance:
.. autoclass:: bec_widgets.cli.client.AbortButton
:members:
:show-inheritance:
.. autoclass:: bec_widgets.cli.client.ResetButton
:members:
:show-inheritance:
```
`````
````

View File

@@ -4,8 +4,8 @@
````{tab} Overview
The [`DAPComboBox`](/api_reference/_autosummary/bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPComboBox) is a widget that extends the functionality of a standard `QComboBox` to allow the user to select a DAP process from all available DAP models.
One of its signals `new_dap_config` is designed to be connected to the [`add_dap(str, str, str)`](/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst#bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.add_dap) slot from the BECWaveformWidget to add a DAP process.
The {py:class}`~bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPComboBox` is a widget that extends the functionality of a standard `QComboBox` to allow the user to select a DAP process from all available DAP models.
One of its signals `new_dap_config` is designed to be connected to the {py:class}`~bec_widgets.widgets.plots.waveform.waveform.Waveform.add_dap_curve` slot from the Waveform widget to add a DAP process.
## Key Features:
- **Select DAP model**: Select one of the available DAP models.
@@ -30,11 +30,6 @@ The following slots are available for the `DAP ComboBox` widget:
- `select_y_axis(str)` : Slot to select the current y axis, emits the `x_axis_updated` signal
- `select_fit_model(str)` : Slot to select the current fit model, emits the `fit_model_updated` signal. If x and y axis are set, it will also emit the `new_dap_config` signal.
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPCombobox.rst
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`Device Browser`](/api_reference/_autosummary/bec_widgets.cli.client.DeviceBrowser) widget provides a user-friendly interface for browsing through all available devices in the current BEC session. As it supports drag functionality, users can easily drag and drop device into other widgets or applications.
The {py:class}`~bec_widgets.cli.client.DeviceBrowser` widget provides a user-friendly interface for browsing through all available devices in the current BEC session. As it supports drag functionality, users can easily drag and drop device into other widgets or applications.
```{note}
The `Device Browser` widget is currently under development. Other widgets may not support drag and drop functionality yet.
@@ -34,6 +34,8 @@ dock_area.device_browser.DeviceBrowser
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceBrowser.rst
.. autoclass:: bec_widgets.cli.client.DeviceBrowser
:members:
:show-inheritance:
```
````

View File

@@ -114,12 +114,16 @@ The following Qt properties are also included:
````{tab} API - ComboBox
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceComboBox.rst
.. autoclass:: bec_widgets.cli.client.DeviceComboBox
:members:
:show-inheritance:
```
````
````{tab} API - LineEdit
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceLineEdit.rst
.. autoclass:: bec_widgets.cli.client.DeviceLineEdit
:members:
:show-inheritance:
```
````

View File

@@ -4,12 +4,12 @@
````{tab} Overview
[`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) is a powerful and flexible container designed to host various widgets and docks within a grid layout. It provides an environment for organizing and managing complex user interfaces, making it ideal for applications that require multiple tools and data visualizations to be displayed simultaneously. BECDockArea is particularly useful for embedding not only visualization tools but also other interactive components, allowing users to tailor their workspace to their specific needs.
`BECDockArea` is a powerful and flexible container designed to host various widgets and docks within a grid layout. It provides an environment for organizing and managing complex user interfaces, making it ideal for applications that require multiple tools and data visualizations to be displayed simultaneously. BECDockArea is particularly useful for embedding not only visualization tools but also other interactive components, allowing users to tailor their workspace to their specific needs.
- **Flexible Dock Management**: Easily add, remove, and rearrange docks within `BECDockArea`, providing a customized layout for different tasks.
- **State Persistence**: Save and restore the state of the dock area, enabling consistent user experiences across sessions.
- **Dock Customization**: Add docks with customizable positions, names, and behaviors, such as floating or closable docks.
- **Integration with Widgets**: Integrate various widgets like [`WaveformWidget`](user.widgets.waveform_widget), [`ImageWidget`](user.widgets.image_widget), and [`MotorMapWidget`](user.widgets.motor_map) into [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea), either as standalone tools or as part of a more complex interface.
- **Integration with Widgets**: Integrate various widgets like [`WaveformWidget`](user.widgets.waveform_widget), [`ImageWidget`](user.widgets.image_widget), and [`MotorMapWidget`](user.widgets.motor_map) into `BECDockArea`, either as standalone tools or as part of a more complex interface.
**BEC Dock Area Components Schema**
@@ -114,7 +114,9 @@ When removing a dock, all widgets within the dock will be removed as well. This
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECDockArea.rst
.. autoclass:: bec_widgets.cli.client.BECDockArea
:members:
:show-inheritance:
```
````

View File

@@ -101,6 +101,8 @@ heatmap_widget.v_max = 1000
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.rst
.. autoclass:: bec_widgets.widgets.plots.heatmap.heatmap.Heatmap
:members:
:show-inheritance:
```
````

View File

@@ -105,6 +105,8 @@ Since the Image Widget does not have prior information about the shape of incomi
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.Image.rst
.. autoclass:: bec_widgets.cli.client.Image
:members:
:show-inheritance:
```
````

View File

@@ -4,8 +4,8 @@
````{tab} Overview
The [`LMFit Dialog`](/api_reference/_autosummary/bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog.LMFitDialog) is a widget that is developed to be used together with the [`Waveform`](/api_reference/_autosummary/bec_widgets.widgets.plots.waveform.waveform.Waveform) widget. The `Waveform` widget allows user to submit a fit request to BEC's [DAP server](https://bec.readthedocs.io/en/latest/developer/getting_started/architecture.html) choosing from a selection of [LMFit models](https://lmfit.github.io/lmfit-py/builtin_models.html#) to fit monitored data sources. The `LMFit Dialog` provides an interface to monitor these fits, including statistics and fit parameters in real time.
Within the `Waveform` widget, the dialog is accessible via the toolbar and will be automatically linked to the current waveform widget. For a more customised use, we can embed the `LMFit Dialog` in a larger GUI using the *BEC Designer*. In this case, one has to connect the [`update_summary_tree`](/api_reference/_autosummary/bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog.LMFitDialog.rst#bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.update_summary_tree) slot of the LMFit Dialog to the [`dap_summary_update`](/api_reference/_autosummary/bec_widgets.widgets.plots.waveform.waveform_widget.Waveform.rst#bec_widgets.widgets.plots.waveform.waveform.Waveform.dap_summary_update) signal of the Waveform widget to ensure its functionality.
The `LMFitDialog` is a widget that is developed to be used together with the `Waveform` widget. The `Waveform` widget allows user to submit a fit request to BEC's [DAP server](https://bec.readthedocs.io/en/latest/developer/getting_started/architecture.html) choosing from a selection of [LMFit models](https://lmfit.github.io/lmfit-py/builtin_models.html#) to fit monitored data sources. The `LMFit Dialog` provides an interface to monitor these fits, including statistics and fit parameters in real time.
Within the `Waveform` widget, the dialog is accessible via the toolbar and will be automatically linked to the current waveform widget. For a more customised use, we can embed the `LMFit Dialog` in a larger GUI using the *BEC Designer*. In this case, one has to connect the `update_summary_tree` slot of the LMFit Dialog to the `dap_summary_update` signal of the Waveform widget to ensure its functionality.
## Key Features:
@@ -34,11 +34,7 @@ waveform.dap_summary_update.connect(lmfit_dialog.update_summary_tree)
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.rst
```
````

View File

@@ -63,6 +63,8 @@ mm1.map(x_name='aptrx', y_name='aptry')
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.MotorMap.rst
.. autoclass:: bec_widgets.cli.client.MotorMap
:members:
:show-inheritance:
```
````

View File

@@ -89,6 +89,8 @@ multi_waveform.export_to_matplotlib()
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.MultiWaveform.rst
.. autoclass:: bec_widgets.cli.client.MultiWaveform
:members:
:show-inheritance:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`PositionIndicator`](/api_reference/_autosummary/bec_widgets.cli.client.PositionIndicator) widget is a simple yet effective tool for visually indicating the position of a motor within its set limits. This widget is particularly useful in applications where it is important to provide a visual clue of the motor's current position relative to its minimum and maximum values. The `PositionIndicator` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
The `PositionIndicator` widget is a simple yet effective tool for visually indicating the position of a motor within its set limits. This widget is particularly useful in applications where it is important to provide a visual clue of the motor's current position relative to its minimum and maximum values. The `PositionIndicator` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
## Key Features:
- **Position Visualization**: Displays the current position of a motor on a linear scale, showing its location relative to the defined limits.
@@ -36,7 +36,7 @@ Within the BEC Designer's [property editor](https://doc.qt.io/qt-6/designer-widg
````{tab} Examples
The `PositionIndicator` widget can be embedded in a [`BECDockArea`](#user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`. Below are examples demonstrating how to create and use the `PositionIndicator` from the CLI and also directly within Code.
The `PositionIndicator` widget can be embedded in a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`. Below are examples demonstrating how to create and use the `PositionIndicator` from the CLI and also directly within Code.
## Example 1 - Creating a Position Indicator in Code
@@ -95,6 +95,8 @@ self.position_indicator.set_value(new_position_value)
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionIndicator.rst
.. autoclass:: bec_widgets.cli.client.PositionIndicator
:members:
:show-inheritance:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`PositionerBox`](/api_reference/_autosummary/bec_widgets.cli.client.PositionerBox) widget provides a graphical user interface to control a positioner device within the BEC environment. This widget allows users to interact with a positioner by setting setpoints, tweaking the motor position, and stopping motion. The device selection can be done via a small button under the device label, through `BEC Designer`, or by using the command line interface (CLI). This flexibility makes the `PositionerBox` an essential tool for tasks involving precise position control.
The `PositionerBox` widget provides a graphical user interface to control a positioner device within the BEC environment. This widget allows users to interact with a positioner by setting setpoints, tweaking the motor position, and stopping motion. The device selection can be done via a small button under the device label, through `BEC Designer`, or by using the command line interface (CLI). This flexibility makes the `PositionerBox` an essential tool for tasks involving precise position control.
## Key Features:
- **Device Selection**: Easily select a positioner device by clicking the button under the device label or by configuring the widget in `BEC Designer`.
@@ -58,6 +58,8 @@ self.positioner_box.set_positioner("motor2")
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionerBox.rst
.. autoclass:: bec_widgets.cli.client.PositionerBox
:members:
:show-inheritance:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`PositionerBox2D`](/api_reference/_autosummary/bec_widgets.cli.client.PositionerBox2D) widget is very similar to the [`PositionerBox`](/user/widgets/positioner_box/positioner_box) but allows controlling two positioners at the same time, in a horizontal and vertical orientation respectively. It is intended primarily for controlling axes which have a perpendicular relationship like that. In other cases, it may be better to use a `PositionerGroup` instead.
The `PositionerBox2D` widget is very similar to the `PositionerBox` but allows controlling two positioners at the same time, in a horizontal and vertical orientation respectively. It is intended primarily for controlling axes which have a perpendicular relationship like that. In other cases, it may be better to use a `PositionerGroup` instead.
The `PositionerBox2D` has the same features as the standard `PositionerBox`, but additionally, step buttons which move the positioner by the selected step size, and tweak buttons which move by a tenth of the selected step size.
@@ -55,6 +55,8 @@ self.positioner_box.set_positioner_verr("samy")
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionerBox2D.rst
.. autoclass:: bec_widgets.cli.client.PositionerBox2D
:members:
:show-inheritance:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`Ring Progress Bar`](/api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar) widget is a circular progress bar designed to visualize the progress of tasks in a clear and intuitive manner. This widget is particularly useful in applications where task progress needs to be represented as a percentage. The `Ring Progress Bar` can be controlled directly via its API or can be hooked up to track the progress of a device readback or scan, providing real-time visual feedback.
The `RingProgressBar` widget is a circular progress bar designed to visualize the progress of tasks in a clear and intuitive manner. This widget is particularly useful in applications where task progress needs to be represented as a percentage. The `Ring Progress Bar` can be controlled directly via its API or can be hooked up to track the progress of a device readback or scan, providing real-time visual feedback.
## Key Features:
- **Circular Progress Visualization**: Displays a circular progress bar to represent task completion.
@@ -98,7 +98,9 @@ progress.set_value([50, 75, 25])
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar.rst
.. autoclass:: bec_widgets.cli.client.RingProgressBar
:members:
:show-inheritance:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`BEC Queue`](/api_reference/_autosummary/bec_widgets.cli.client.BECQueue) widget provides a real-time display and control of the BEC scan queue, allowing users to monitor, manage, and control the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. Additionally, it provides control options to stop individual scans, stop the entire queue, resume, and reset the queue, making it a powerful tool for managing scan operations in the BEC environment.
The `BECQueue` widget provides a real-time display and control of the BEC scan queue, allowing users to monitor, manage, and control the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. Additionally, it provides control options to stop individual scans, stop the entire queue, resume, and reset the queue, making it a powerful tool for managing scan operations in the BEC environment.
## Key Features:
- **Real-Time Queue Monitoring**: Displays the current state of the BEC scan queue, with automatic updates as the queue changes.
@@ -39,6 +39,8 @@ Once the widget is added, it will automatically display the current scan queue
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECQueue.rst
.. autoclass:: bec_widgets.cli.client.BECQueue
:members:
:show-inheritance:
```
````

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