1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-07 09:17:53 +02:00

Compare commits

..

142 Commits

Author SHA1 Message Date
8a53b309fc fix(ads_profile_utils): profiles_dir directs to service config bec_widgets_settings 2025-10-14 17:21:10 +02:00
06f19c30ad test(advanced_dock_area): tests fixed 2025-10-10 15:05:52 +02:00
df5f069ea3 feat(advanced_dock_area): add 2D positioner box to device actions 2025-10-10 15:05:52 +02:00
5e68a08814 fix(advanced_dock_area): added terminal,bec_shell, removed VSCode 2025-10-10 15:05:52 +02:00
8a8cd5742e fix(web_console): added startup kwarg 2025-10-10 15:05:52 +02:00
4d06654c11 fix(advanced_dock_area): vs code removed from available docks 2025-10-10 15:05:52 +02:00
cc842cf283 feat(help-inspector): add help inspector widget 2025-10-09 16:11:50 +02:00
cf773835ae fix(signal_label): dispatcher unsubscribed in the cleanup 2025-10-09 15:42:36 +02:00
1f40da86b0 fix(client): abort, reset, stop button removed from RPC access 2025-10-09 15:42:36 +02:00
0560519853 test(color_utils): cleanup for pyqtgraph 2025-10-09 15:42:36 +02:00
4e9df05d37 test(device_input_base): added qtbot 2025-10-09 15:42:36 +02:00
209905030c test(busy_loader): tests added 2025-10-09 15:42:36 +02:00
732bb533db feat(busy_loader): busy loader added to bec widget base class 2025-10-09 15:42:36 +02:00
844cfd6a32 refactor(device_manager_view): added labels to main toolbar 2025-10-09 15:42:36 +02:00
db73613a81 fix(available_device_resources): top toolbar size fixed 2025-10-09 15:42:36 +02:00
98ac90f1ac perf(device_table_view): text wrapper delegate removed since it was not working correctly anyway 2025-10-09 15:42:36 +02:00
bf1bff38c8 fix(device_manager_view): removed custom styling for overlay 2025-10-09 15:42:36 +02:00
ffb6bdebe3 refactor(examples): wrong main app removed 2025-10-09 15:42:36 +02:00
f5534d1a6e feat(main_app): device manager implemented into main app 2025-10-09 15:42:36 +02:00
67da2b4569 feat(actions): actions can be created with label text with beside or under alignment 2025-10-09 15:42:36 +02:00
4966373884 fix: mark processEvents for checks 2025-10-09 15:42:36 +02:00
471957f1cc refactor: cleanup 2025-10-09 15:42:36 +02:00
cf74e048b2 fix: preset classes for config dialog 2025-10-09 15:42:36 +02:00
b4689958ef fix: tests 2025-10-09 15:42:36 +02:00
3233c276a6 style: imports 2025-10-09 15:42:36 +02:00
071c477287 refactor: redo device tester 2025-10-09 15:42:36 +02:00
3f4bb83c0c fix: check plugin exists before loading 2025-10-09 15:42:36 +02:00
a46c557652 feat: allow setting config in redis 2025-10-09 15:42:36 +02:00
de8908e9f9 style: typo 2025-10-09 15:42:36 +02:00
6499e665a9 fix: slightly improve theming 2025-10-09 15:42:36 +02:00
dcf4ee4777 fix: don't use deprecated api for CDockWidget 2025-10-09 15:42:36 +02:00
5888b2e608 feat(device_manager): add device dialog with presets 2025-10-09 15:42:36 +02:00
b2aaf5d001 refactor: genericise config form 2025-10-09 15:42:36 +02:00
907f4e4375 fix: device table theming 2025-10-09 15:42:36 +02:00
4b16f8f718 refactor: available devices add+remove from toolbar 2025-10-09 15:42:36 +02:00
7ab6c0ee94 fix: add all devices to test list 2025-10-09 15:42:36 +02:00
b0456489a2 feat: connect available devices to doc and yaml views 2025-10-09 15:42:36 +02:00
8284ade4ad feat: add/remove functionality for device table
refactor: use list of configs for general interfaces
2025-10-09 15:42:36 +02:00
d2434d0112 refactor: util for MimeData 2025-10-09 15:42:36 +02:00
9f5179fde0 feat(dm): apply shared selection signal util to view 2025-10-09 15:42:36 +02:00
31863e4aa0 feat: add shared selection signal util 2025-10-09 15:42:36 +02:00
bb8fe61381 feat: connect config update to available devices 2025-10-09 15:42:36 +02:00
b5e30f18aa fix: allow setting state with other conformation of config 2025-10-09 15:42:36 +02:00
e2a12695ea feat: prepare available devices for dragging config 2025-10-09 15:42:36 +02:00
cbb02d7529 feat(device_table): prepare table for drop action 2025-10-09 15:42:36 +02:00
e449c61620 feat: add available devices to manager view 2025-10-09 15:42:36 +02:00
2448aa30cc fix(dm): add constants.py 2025-10-09 15:42:36 +02:00
86727d8138 feat: add available device resource browser 2025-10-09 15:42:36 +02:00
39b070f8dc feat: add ListOfExpandableFrames util 2025-10-09 15:42:36 +02:00
52798d8363 chore: update qtmonaco dependency 2025-10-09 15:42:36 +02:00
86bb09f776 refactor: refactor device_manager_view 2025-10-09 15:42:36 +02:00
76e45b85a8 feat(dm-view): initial commit for config_view, ophyd_test and dm_widget 2025-10-09 15:42:36 +02:00
8f6385a97b fix(colors): accent colors fetching if theme not provided 2025-10-09 15:42:36 +02:00
a447ac0b96 test(main_app): test extended 2025-10-09 15:42:36 +02:00
341dc36f65 feat(main_app):views with examples for enter and exit hook 2025-10-09 15:42:36 +02:00
cfaaef95fd feat(main_app): main app with interactive app switcher 2025-10-09 15:42:36 +02:00
ba113aaaa2 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-10-09 15:42:36 +02:00
f3fd9964cf feat: add SafeConnect 2025-10-09 15:42:36 +02:00
0ab5b52f10 fix: process all deletion events before applying a new theme.
Note: this can be dropped once qthemes is updated.
2025-10-09 15:42:36 +02:00
b560f6b67a refactor: move to qthemes 1.1.2 2025-10-09 15:42:33 +02:00
6b3e087cad test: apply theme on qapp creation 2025-10-09 15:42:27 +02:00
a6d419a42e refactor(spinner): improve enum access 2025-10-09 15:42:27 +02:00
d8eeaa2e68 fix(themes): move apply theme from BECWidget class to server init 2025-10-09 15:42:27 +02:00
325d8fe5ba fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects 2025-10-09 15:42:27 +02:00
8201aec11f test: fix tests for qtheme v1 2025-10-09 15:42:25 +02:00
72009023bc fix(serializer): remove deprecated serializer 2025-10-09 15:42:06 +02:00
b8a6596231 ci: add artifact upload 2025-10-09 15:42:06 +02:00
3cc4d19e99 test: fixes after theme changes 2025-10-09 15:42:06 +02:00
5730814520 build: add missing darkdetect dependency 2025-10-09 15:42:06 +02:00
8d6e861986 fix(compact_popup): import from qtpy instead of pyside6 2025-10-09 15:42:06 +02:00
7e94ad0c0c chore: fix formatter 2025-10-09 15:42:06 +02:00
caf89973f9 fix: compact popup layout spacing 2025-10-09 15:42:06 +02:00
2bdfa7e678 fix: remove pyqtgraph styling logic 2025-10-09 15:42:06 +02:00
d5836b1b1a fix: tree items due to pushbutton margins 2025-10-09 15:42:06 +02:00
07c2899ab1 fix: device combobox change paint event to stylesheet change 2025-10-09 15:42:06 +02:00
4a0f72ac00 fix(toolbar): toolbar menu button fixed 2025-10-09 15:42:06 +02:00
42a636c54a fix:queue abort button fixed 2025-10-09 15:42:06 +02:00
1e33e7ee2d fix(bec_widgets): adapt to bec_qthemes 1.0 2025-10-09 15:42:06 +02:00
5c6001542b build(bec_qthemes): version 1.0 dependency 2025-10-09 15:42:04 +02:00
0a6ac071e8 refactor(advanced_dock_area): profile tools moved to separate module 2025-10-09 15:41:53 +02:00
c1f0e23678 fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault 2025-10-09 15:41:53 +02:00
d0c7a3a65d feat(advanced_dock_area): ads has default direction 2025-10-09 15:41:53 +02:00
f9fc986aa7 refactor(advanced_dock_area): ads changed to separate widget 2025-10-09 15:41:53 +02:00
42f5aa513c fix(bec_widgets): by default the linux display manager is switched to xcb 2025-10-09 15:41:53 +02:00
dcacbd1d9f feat(advanced_dock_area): added ads based dock area with profiles 2025-10-09 15:41:53 +02:00
df583c29bf refactor(bec_main_window): main app theme renamed to View 2025-10-09 15:41:53 +02:00
39be12a211 feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-10-09 15:41:53 +02:00
4d05c0d187 fix(widget_state_manager): state manager can save to already existing settings
wip widget state manager saving loading file logic
2025-10-09 15:41:53 +02:00
690c240bf0 fix(widget_state_manager): state manager can save all properties recursively 2025-10-09 15:41:53 +02:00
153249fb75 refactor(widget_io): ancestor hierarchy methods consolidated 2025-10-09 15:41:53 +02:00
2dfc3f6ce6 feat(widget_io): widget hierarchy find_ancestor added 2025-10-09 15:41:53 +02:00
cd1f759573 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-10-09 15:41:53 +02:00
3282c739e5 refactor(bec_connector): signals renamed 2025-10-09 15:41:53 +02:00
69fc4582f6 fix(bec_connector): added name established signal for listeners 2025-10-09 15:41:53 +02:00
b6fe5ddf48 fix(bec_connector): dedicated remove signal added for listeners 2025-10-09 15:41:53 +02:00
aac5fa2fad build: PySide6-QtAds dependency added 2025-10-09 15:41:53 +02:00
28ac9c5cc3 build(bec_lib): version bump to 3.69.3 2025-10-09 15:36:18 +02:00
1dd20d5986 test(deviceconfig-form-update): Add onFailure default to test 2025-10-09 15:36:18 +02:00
semantic-release
13299aeeb3 2.40.0
Automatically generated by python-semantic-release
2025-10-08 11:41:33 +00:00
d681ba538b fix(waveform): cleanup of scan_history dialog if not closed manually before widget 2025-10-08 13:40:48 +02:00
2bf489600e fix(waveform): safeguard for _scan_history_closed 2025-10-08 13:40:48 +02:00
7e88a002b6 fix(waveform): safeguard for if scan_item is a list 2025-10-08 13:40:48 +02:00
20a59af648 fix(curve_tree): scans are always fetched by scan ids 2025-10-08 13:40:48 +02:00
540cfc37be fix(waveform): safeguard added to the fetching history data 2025-10-08 13:40:48 +02:00
e59f27a22d fix(waveform): if scan id and scan number is provided, the scan is fetched from the scan id 2025-10-08 13:40:48 +02:00
df8065ea40 fix(curve_tree): safeguard fetching scan numbers from BEC client 2025-10-08 13:40:48 +02:00
2f3dc2ce6b build(bec_lib): bec_lib dependency raised to 3.68 2025-10-08 13:40:48 +02:00
a006f95f21 test(plotting_framework_e2e): fetching history curve 2025-10-08 13:40:48 +02:00
8111a4a21b fix(curve_tree): fetching scan numbers directly from the bec client 2025-10-08 13:40:48 +02:00
962ab774e6 fix(waveform): fetching scan number is not done from list but from .get_by_scan_number 2025-10-08 13:40:48 +02:00
2f798be7b0 refactor(test_waveform): test waveform renamed 2025-10-08 13:40:48 +02:00
5a5d32312b test(waveform,curve_tree): test extended to cover history curve behaviour 2025-10-08 13:40:48 +02:00
0844a9e119 test(conftest): suppress_message_box for error popups fixture autouse True 2025-10-08 13:40:48 +02:00
db7dd4f8d4 fix(waveform): x_data checked with is scalar instead of len() 2025-10-08 13:40:48 +02:00
f083dff612 feat(waveform): new type of curve - history curve 2025-10-08 13:40:48 +02:00
4be70580a6 refactor(waveform): separate method to fetch scan item from history 2025-10-08 13:40:48 +02:00
d19001c94e fix(waveform): update x suffix label with x property change, do not wait for next update cycle 2025-10-08 13:40:48 +02:00
f25f86522f chore: add dependabot config 2025-10-07 11:12:10 +02:00
semantic-release
948283bc13 2.39.1
Automatically generated by python-semantic-release
2025-10-07 09:00:10 +00:00
50696bce4c fix: explicitly pass the cached readout flag 2025-10-07 10:59:22 +02: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
130 changed files with 6733 additions and 1804 deletions

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

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,171 @@
# CHANGELOG
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## 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,200 @@
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.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.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",
)
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
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)
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

@@ -2,13 +2,19 @@
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.examples.device_manager_view.device_manager_view import DeviceManagerView
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):
@@ -31,9 +37,6 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_overlay(self):
self._overlay_widget.setStyleSheet(
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
@@ -41,32 +44,75 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
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.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__":
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)
device_manager.show()
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

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from qtpy.QtCore import QEventLoop
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class ViewBase(QWidget):
"""Wrapper for a content widget used inside the main app's stacked view.
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
Args:
content (QWidget): The actual view widget to display.
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
self.view_id = id
self.view_title = title
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.content.setParent(None)
self.content = content
self.layout().addWidget(content)
@SafeSlot()
def on_enter(self) -> None:
"""Called after the view becomes current/visible.
Default implementation does nothing. Override in subclasses.
"""
pass
@SafeSlot()
def on_exit(self) -> bool:
"""Called before the view is switched away/hidden.
Return True to allow switching, or False to veto.
Default implementation allows switching.
"""
return True
####################################################################################################
# Example views for demonstration/testing purposes
####################################################################################################
# --- Popup UI version ---
class WaveformViewPopup(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.waveform = Waveform(parent=self)
self.set_content(self.waveform)
@SafeSlot()
def on_enter(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("Configure Waveform View")
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
# same as in the CurveRow used in waveform
self.device_edit = DeviceComboBox(parent=self)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(label)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
v = QVBoxLayout(dialog)
v.addLayout(form)
v.addWidget(buttons)
if dialog.exec_() == QDialog.Accepted:
self.waveform.plot(
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
)
@SafeSlot()
def on_exit(self) -> bool:
ans = QMessageBox.question(
self,
"Switch and clear?",
"Do you want to switch views and clear the plot?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans == QMessageBox.Yes:
self.waveform.clear_all()
return True
return False
# --- Inline stacked UI version ---
class WaveformViewInline(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# Root layout for this view uses a stacked layout
self.stack = QStackedLayout()
container = QWidget(self)
container.setLayout(self.stack)
self.set_content(container)
# --- Page 0: Settings page (inline form)
self.settings_page = QWidget()
sp_layout = QVBoxLayout(self.settings_page)
sp_layout.setContentsMargins(16, 16, 16, 16)
sp_layout.setSpacing(12)
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
self.device_edit = DeviceComboBox(parent=self.settings_page)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self.settings_page)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(title)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
btn_row = QHBoxLayout()
ok_btn = QPushButton("OK", parent=self.settings_page)
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
btn_row.addStretch(1)
btn_row.addWidget(cancel_btn)
btn_row.addWidget(ok_btn)
sp_layout.addLayout(form)
sp_layout.addLayout(btn_row)
# --- Page 1: Waveform page
self.waveform_page = QWidget()
wf_layout = QVBoxLayout(self.waveform_page)
wf_layout.setContentsMargins(0, 0, 0, 0)
self.waveform = Waveform(parent=self.waveform_page)
wf_layout.addWidget(self.waveform)
# --- Page 2: Exit confirmation page (inline)
self.confirm_page = QWidget()
cp_layout = QVBoxLayout(self.confirm_page)
cp_layout.setContentsMargins(16, 16, 16, 16)
cp_layout.setSpacing(12)
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
cp_buttons = QHBoxLayout()
no_btn = QPushButton("No", parent=self.confirm_page)
yes_btn = QPushButton("Yes", parent=self.confirm_page)
cp_buttons.addStretch(1)
cp_buttons.addWidget(no_btn)
cp_buttons.addWidget(yes_btn)
cp_layout.addWidget(qlabel)
cp_layout.addLayout(cp_buttons)
# Add pages to the stack
self.stack.addWidget(self.settings_page) # index 0
self.stack.addWidget(self.waveform_page) # index 1
self.stack.addWidget(self.confirm_page) # index 2
# Wire settings buttons
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
cancel_btn.clicked.connect(self._show_waveform_without_changes)
# Prepare result holder for the inline confirmation
self._exit_choice_yes = None
yes_btn.clicked.connect(lambda: self._exit_reply(True))
no_btn.clicked.connect(lambda: self._exit_reply(False))
@SafeSlot()
def on_enter(self) -> None:
# Always start on the settings page when entering
self.stack.setCurrentIndex(0)
@SafeSlot()
def on_exit(self) -> bool:
# Show inline confirmation page and synchronously wait for a choice
# -> trick to make the choice blocking, however popup would be cleaner solution
self._exit_choice_yes = None
self.stack.setCurrentIndex(2)
loop = QEventLoop()
self._exit_loop = loop
loop.exec_()
if self._exit_choice_yes:
self.waveform.clear_all()
return True
# Revert to waveform view if user cancelled switching
self.stack.setCurrentIndex(1)
return False
def _apply_settings_and_show_waveform(self):
dev = self.device_edit.currentText()
sig = self.entry_edit.currentText()
if dev and sig:
self.waveform.plot(y_name=dev, y_entry=sig)
self.stack.setCurrentIndex(1)
def _show_waveform_without_changes(self):
# Just show waveform page without plotting
self.stack.setCurrentIndex(1)
def _exit_reply(self, yes: bool):
self._exit_choice_yes = bool(yes)
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
self._exit_loop.quit()

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(
@@ -129,6 +104,7 @@ class AdvancedDockArea(RPCBase):
movable: "bool" = True,
start_floating: "bool" = False,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
**kwargs,
) -> "BECWidget":
"""
Create a new widget (or reuse an instance) and add it as a dock.
@@ -141,6 +117,7 @@ class AdvancedDockArea(RPCBase):
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
**kwargs: The keyword arguments for the widget.
Returns:
The widget instance.
"""
@@ -236,6 +213,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 +1097,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 +2453,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 +4029,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 +4215,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 +5004,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"""
@@ -5470,6 +5465,8 @@ class Waveform(RPCBase):
color: "str | None" = None,
label: "str | None" = None,
dap: "str | None" = None,
scan_id: "str | None" = None,
scan_number: "int | None" = None,
**kwargs,
) -> "Curve":
"""
@@ -5492,6 +5489,10 @@ class Waveform(RPCBase):
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
Returns:
Curve: The curve object.
@@ -5534,11 +5535,11 @@ class Waveform(RPCBase):
def update_with_scan_history(self, scan_index: "int" = None, scan_id: "str" = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
"""
@rpc_call

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,214 +0,0 @@
from typing import List
import PySide6QtAds as QtAds
import yaml
from bec_qthemes import material_icon
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QPushButton,
QSizePolicy,
QSplitter,
QStackedLayout,
QTreeWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
AvailableDeviceResources,
)
from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import (
DeviceManagerOphydTest,
)
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
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.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, *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)
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.available_devices = AvailableDeviceResources(self)
self.device_table_view = DeviceTableView(self)
# Placeholder
self.dm_config_view = DMConfigView(self)
# Placeholder for ophyd test
WebConsole.startup_cmd = "ipython"
self.ophyd_test = DeviceManagerOphydTest(self)
self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self)
self.ophyd_test_dock.setWidget(self.ophyd_test)
# Create the dock widgets
self.available_devices_dock = QtAds.CDockWidget("Explorer", self)
self.available_devices_dock.setWidget(self.available_devices)
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Table will be central widget
self.dock_manager.setCentralWidget(self.device_table_view_dock)
self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
)
monaco_yaml_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock
)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area
)
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, 5, 3], [5, 5])
# Connect slots
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
self.device_table_view.model.devices_reset.connect(
self.available_devices.update_devices_state
)
####### 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.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.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)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager_view = DeviceManagerView()
config = device_manager_view.client.device_manager._get_redis_device_config()
device_manager_view.device_table_view.set_device_config(config)
device_manager_view.show()
device_manager_view.setWindowTitle("Device Manager View")
device_manager_view.resize(1200, 800)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -58,7 +58,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
"wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
@@ -108,12 +108,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
#
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)

View File

@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
def set_read_value(self, value):
self.read_value = value
def read(self):
def read(self, cached=False):
return self.signals
def set_limits(self, limits):

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,22 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
def _update_overlay_theme(self, theme: str):
try:
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and hasattr(overlay, "update_palette"):
overlay.update_palette()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
def get_help_md(self) -> str:
"""
Method to override in subclasses to provide help text in markdown format.
Returns:
str: The help text in markdown format.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
@@ -150,6 +251,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

@@ -33,10 +33,10 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded
self._title_text = f"<b>{title}</b>"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
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)

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,238 @@
"""Module providing a simple help inspector tool for QtWidgets."""
from functools import partial
from typing import Callable
from uuid import uuid4
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class HelpInspector(BECWidget, QtWidgets.QWidget):
"""
A help inspector widget that allows to inspect other widgets in the application.
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
The inspector also allows to register custom callback that are called with the inspected widget
as argument. This may be useful in the future to hook up more callbacks with custom signals.
Args:
parent (QWidget | None): The parent widget of the help inspector.
client: Optional client for BECWidget functionality.
size (tuple[int, int]): Optional size of the icon for the help inspector.
"""
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self._app = QtWidgets.QApplication.instance()
layout = QtWidgets.QHBoxLayout(self) # type: ignore
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._active = False
self._init_ui()
self._callbacks = {}
# Register the default callbacks
self._register_default_callbacks()
# Connect the button toggle signal
self._button.toggled.connect(self._toggle_mode)
def _init_ui(self):
"""Init the UI components."""
colors: AccentColors = get_accent_colors()
self._button = QtWidgets.QToolButton(self.parent())
self._button.setCheckable(True)
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
self._button.setText("Help Inspect Tool")
self._button.setIcon(self._icon_unchecked())
self._button.setToolTip("Click to enter Help Mode")
self.layout().addWidget(self._button)
def apply_theme(self, theme: str) -> None:
colors = get_accent_colors()
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
if self._active:
self._button.setIcon(self._icon_checked())
else:
self._button.setIcon(self._icon_unchecked())
@SafeSlot(bool)
def _toggle_mode(self, enabled: bool):
"""
Toggle the help inspection mode.
Args:
enabled (bool): Whether to enable or disable the help inspection mode.
"""
if self._app is None:
self._app = QtWidgets.QApplication.instance()
self._active = enabled
if enabled:
self._app.installEventFilter(self)
self._button.setIcon(self._icon_checked())
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
else:
self._app.removeEventFilter(self)
self._button.setIcon(self._icon_unchecked())
self._button.setChecked(False)
QtWidgets.QApplication.restoreOverrideCursor()
def eventFilter(self, obj, event):
"""
Filter events to capture Key_Escape event, and mouse clicks
if event filter is active. Any click event on a widget is suppressed, if
the Inspector is active, and the registered callbacks are called with
the clicked widget as argument.
Args:
obj (QObject): The object that received the event.
event (QEvent): The event to filter.
"""
if (
event.type() == QtCore.QEvent.KeyPress
and event.key() == QtCore.Qt.Key_Escape
and self._active
):
self._toggle_mode(False)
return super().eventFilter(obj, event)
if self._active and event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
widget = self._app.widgetAt(event.globalPos())
if widget:
if widget is self or self.isAncestorOf(widget):
self._toggle_mode(False)
return True
for cb in self._callbacks.values():
try:
cb(widget)
except Exception as e:
print(f"Error occurred in callback {cb}: {e}")
return True
return super().eventFilter(obj, event)
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
"""
Register a callback to be called when a widget is inspected.
The callback should be callable with the following signature:
callback(widget: QWidget) -> None
Args:
callback (Callable[[QWidget], None]): The callback function to register.
Returns:
str: A unique ID for the registered callback.
"""
cb_id = str(uuid4())
self._callbacks[cb_id] = callback
return cb_id
def unregister_callback(self, cb_id: str):
"""Unregister a previously registered callback."""
self._callbacks.pop(cb_id, None)
def _register_default_callbacks(self):
"""Default behavior: publish tooltip, docstring, bec_help"""
def cb_doc(widget: QtWidgets.QWidget):
docstring = widget.__doc__ or "No documentation available."
self.widget_docstring.emit(docstring)
def cb_help(widget: QtWidgets.QWidget):
tooltip = widget.toolTip() or "No tooltip available."
self.widget_tooltip.emit(tooltip)
def cb_bec_help(widget: QtWidgets.QWidget):
help_text = None
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
try:
help_text = widget.get_help_md()
except Exception as e:
logger.debug(f"Error retrieving help text from {widget}: {e}")
if help_text is None:
help_text = widget.toolTip() or "No help available."
if not isinstance(help_text, str):
logger.error(
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
)
help_text = str(help_text)
self.bec_widget_help.emit(help_text)
self.register_callback(cb_doc)
self.register_callback(cb_help)
self.register_callback(cb_bec_help)
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QtWidgets.QApplication(sys.argv)
main_window = QtWidgets.QMainWindow()
apply_theme("dark")
main_window.setWindowTitle("Help Inspector Test")
central_widget = QtWidgets.QWidget()
main_layout = QtWidgets.QVBoxLayout(central_widget)
dark_mode_button = DarkModeButton(parent=main_window)
main_layout.addWidget(dark_mode_button)
help_inspector = HelpInspector()
main_layout.addWidget(help_inspector)
test_button = QtWidgets.QPushButton("Test Button")
test_button.setToolTip("This is a test button.")
test_line_edit = QtWidgets.QLineEdit()
test_line_edit.setToolTip("This is a test line edit.")
test_label = QtWidgets.QLabel("Test Label")
test_label.setToolTip("")
box = PositionerBox()
layout_1 = QtWidgets.QHBoxLayout()
layout_1.addWidget(test_button)
layout_1.addWidget(test_line_edit)
layout_1.addWidget(test_label)
layout_1.addWidget(box)
main_layout.addLayout(layout_1)
doc_label = QtWidgets.QLabel("Docstring will appear here.")
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
main_layout.addWidget(doc_label)
main_layout.addWidget(tool_tip_label)
main_layout.addWidget(bec_help_label)
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
help_inspector.widget_docstring.connect(doc_label.setText)
help_inspector.bec_widget_help.connect(bec_help_label.setText)
main_window.setCentralWidget(central_widget)
main_window.resize(400, 200)
main_window.show()
sys.exit(app.exec())

View File

@@ -4,18 +4,18 @@ from re import Pattern
from typing import Generic, Iterable, NamedTuple, TypeVar
from bec_lib.logger import bec_logger
from more_itertools import consume
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QListWidgetItem, QWidget
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QListWidget
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
_SORT_KEY_ROLE = 117
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
@@ -37,7 +37,7 @@ class ListOfExpandableFrames(QListWidget, Generic[_EF]):
self._item_dict = {}
return super().clear()
def add_item(self, id: str, *args, **kwargs) -> _EF:
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:
@@ -56,8 +56,8 @@ class ListOfExpandableFrames(QListWidget, Generic[_EF]):
item_widget.adjustSize()
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
item = QListWidgetItem(self)
item.setData(_SORT_KEY_ROLE, id) # used for sorting
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))
@@ -69,9 +69,9 @@ class ListOfExpandableFrames(QListWidget, Generic[_EF]):
self._item_dict[id] = self.item_tuple(item, item_widget)
item.setSizeHint(item_widget.sizeHint())
return item_widget
return (item, item_widget)
def sort_by_key(self, role=_SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
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))

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 == "under":
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
else:
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
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,
@@ -49,9 +50,9 @@ from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.worksp
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
@@ -317,16 +318,22 @@ class AdvancedDockArea(BECWidget, QWidget):
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
"positioner_box_2D": (
PositionerBox2D.ICON_NAME,
"Add Device 2D Box",
"PositionerBox2D",
),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
@@ -443,6 +450,18 @@ class AdvancedDockArea(BECWidget, QWidget):
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
)
elif key == "bec_shell":
act.triggered.connect(
lambda _, t=widget_type: self.new(
widget=t,
closable=True,
startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}",
)
)
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
@@ -517,6 +536,7 @@ class AdvancedDockArea(BECWidget, QWidget):
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
**kwargs,
) -> BECWidget:
"""
Create a new widget (or reuse an instance) and add it as a dock.
@@ -529,6 +549,7 @@ class AdvancedDockArea(BECWidget, QWidget):
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
**kwargs: The keyword arguments for the widget.
Returns:
The widget instance.
"""
@@ -536,7 +557,9 @@ class AdvancedDockArea(BECWidget, QWidget):
# 1) Instantiate or look up the widget
if isinstance(widget, str):
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
widget = cast(
BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs)
)
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,
@@ -901,6 +924,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)

View File

@@ -1,5 +1,6 @@
import os
from bec_lib.client import BECClient
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QSettings
@@ -9,7 +10,18 @@ _USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
def profiles_dir() -> str:
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
client = BECClient()
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
if bec_widgets_settings is not None:
bec_widgets_setting_path = bec_widgets_settings.get("base_path")
else:
bec_widgets_setting_path = None
if bec_widgets_setting_path:
default_path = os.path.join(bec_widgets_setting_path, "profiles")
else:
default_path = _USER_PROFILES_DIR
path = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
os.makedirs(path, exist_ok=True)
return path

File diff suppressed because one or more lines are too long

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

@@ -88,7 +88,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if not self._check_device_is_valid(device):
return
data = self.dev[device].read()
data = self.dev[device].read(cached=True)
self._on_device_readback(
device,
self._device_ui_components(device),

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

@@ -1,12 +0,0 @@
from typing import Any, Callable, Generator, Iterable, TypeVar
_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

View File

@@ -1,27 +1,31 @@
from textwrap import dedent
from typing import NamedTuple
from uuid import uuid4
from bec_qthemes import material_icon
from qtpy.QtCore import QSize
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 = (
f"Device defined with multiple names! Please check:\n {'\n '.join(spec.names)}\n"
"Device defined with multiple names! Please check:\n " + "\n ".join(spec.names)
if len(spec.names) > 1
else ""
)
source_warning = (
f"Device found in multiple source files! Please check:\n {'\n '.join(spec._source_files)}"
"Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files)
if len(spec._source_files) > 1
else ""
)
@@ -35,11 +39,10 @@ class _DeviceEntryWidget(QFrame):
self._device_spec = device_spec
self.included: bool = False
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setFrameStyle(0)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(2, 2, 2, 2)
self.setLayout(self._layout)
self.setup_title_layout(device_spec)
@@ -106,12 +109,27 @@ class _DeviceEntry(NamedTuple):
class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
selected_devices = Signal(list)
def __init__(
self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs
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)
@@ -119,16 +137,20 @@ class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
self.setMinimumSize(self.device_list.sizeHint())
self._update_num_included()
self.add_to_composition_button.clicked.connect(self.test)
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)
@@ -159,6 +181,17 @@ class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
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())
@@ -169,9 +202,6 @@ class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
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 test(self, *args):
print(self.get_selection())
def __repr__(self) -> str:
return f"{self.__class__.__name__}: {self.title_text}"

View File

@@ -1,16 +1,39 @@
from functools import partial
from typing import TYPE_CHECKING
from bec_qthemes import material_icon
from PySide6.QtWidgets import QFrame
from qtpy.QtCore import QMetaObject
from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout
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):
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)
@@ -21,46 +44,13 @@ class Ui_AvailableDeviceGroup(object):
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.delete_tag_button = QToolButton(AvailableDeviceGroup)
self.delete_tag_button.setObjectName("delete_tag_button")
title_layout.addWidget(self.delete_tag_button)
self.remove_from_composition_button = QToolButton(AvailableDeviceGroup)
self.remove_from_composition_button.setObjectName("remove_from_composition_button")
title_layout.addWidget(self.remove_from_composition_button)
self.add_to_composition_button = QToolButton(AvailableDeviceGroup)
self.add_to_composition_button.setObjectName("add_to_composition_button")
title_layout.addWidget(self.add_to_composition_button)
self.remove_all_button = QToolButton(AvailableDeviceGroup)
self.remove_all_button.setObjectName("remove_all_from_composition_button")
title_layout.addWidget(self.remove_all_button)
self.add_all_button = QToolButton(AvailableDeviceGroup)
self.add_all_button.setObjectName("add_all_to_composition_button")
title_layout.addWidget(self.add_all_button)
self.device_list = QListWidget(AvailableDeviceGroup)
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)
self.set_icons()
AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box)
QMetaObject.connectSlotsByName(AvailableDeviceGroup)
def set_icons(self):
icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False)
self.delete_tag_button.setIcon(icon("delete"))
self.delete_tag_button.setToolTip("Delete tag group")
self.remove_from_composition_button.setIcon(icon("remove"))
self.remove_from_composition_button.setToolTip("Remove selected from composition")
self.add_to_composition_button.setIcon(icon("add"))
self.add_to_composition_button.setToolTip("Add selected to composition")
self.remove_all_button.setIcon(icon("chips"))
self.remove_all_button.setToolTip("Remove all with this tag from composition")
self.add_all_button.setIcon(icon("add_box"))
self.add_all_button.setToolTip("Add all with this tag to composition")

View File

@@ -1,11 +1,14 @@
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.available_device_resources._util import (
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 (
@@ -15,49 +18,91 @@ from bec_widgets.widgets.control.device_manager.components.available_device_reso
HashableDevice,
get_backend,
)
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
def __init__(self, parent=None, **kwargs):
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.sort_by_key()
self.device_groups_list.sortItems()
def _add_device_group(self, device_group: str, devices: set[HashableDevice]):
self.device_groups_list.add_item(
device_group, self.device_groups_list, device_group, devices, expanded=False
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,
)
def _reset_devices_state(self):
for device_group in self.device_groups_list.widgets():
device_group.reset_devices_state()
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)
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 update_devices_state(self, config_list: list[dict[str, Any]]):
self.set_devices_state(yield_only_passing(HashableDevice.model_validate, config_list), True)
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):
@@ -76,7 +121,7 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
widget = AvailableDeviceResources()
widget.set_devices_state(
widget._set_devices_state(
list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True
)
widget.show()

View File

@@ -1,19 +1,61 @@
from __future__ import annotations
import itertools
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QComboBox,
QHBoxLayout,
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):
@@ -23,16 +65,33 @@ class Ui_availableDeviceResources(object):
self.verticalLayout = QVBoxLayout(availableDeviceResources)
self.verticalLayout.setObjectName("verticalLayout")
self.search_layout = QHBoxLayout()
self.verticalLayout.addLayout(self.search_layout)
self.search_layout.addWidget(QLabel("Filter groups: "))
self.search_box = QLineEdit()
self.search_layout.addWidget(self.search_box)
self.search_layout.addWidget(QLabel("Group by: "))
self.grouping_selector = QComboBox()
self.search_layout.addWidget(self.grouping_selector)
self._add_toolbar()
self.device_groups_list = ListOfExpandableFrames(
# 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")
@@ -40,12 +99,13 @@ class Ui_availableDeviceResources(object):
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(2)
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())
@@ -53,3 +113,23 @@ class Ui_availableDeviceResources(object):
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

@@ -12,13 +12,19 @@ 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
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()
@@ -73,24 +79,31 @@ def _devices_from_file(file: str, include_source: bool = True):
class _ConfigFileBackend(DeviceResourceBackend):
def __init__(self) -> None:
self._raw_device_set: set[
HashableDevice
] = self._get_config_from_backup_files() | self._get_configs_from_plugin_files(
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
)
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 = glob("*.yaml", root_dir=dir)
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 files)),
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)))
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 {
@@ -115,7 +128,7 @@ class _ConfigFileBackend(DeviceResourceBackend):
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))
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]
@@ -125,7 +138,3 @@ class _ConfigFileBackend(DeviceResourceBackend):
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}
def get_backend() -> DeviceResourceBackend:
return _ConfigFileBackend()

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

@@ -2,17 +2,22 @@
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)
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)
@@ -35,27 +40,33 @@ class DMConfigView(BECWidget, QtWidgets.QWidget):
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.setStyleSheet(
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
)
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: dict):
def on_select_config(self, device: list[dict]):
"""Handle selection of a device from the device table."""
if not device:
if len(device) != 1:
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
else:
text = yaml.dump(device, default_flow_style=False)
self.stacked_layout.setCurrentWidget(self.monaco_editor)
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
@@ -66,6 +77,24 @@ if __name__ == "__main__":
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()
config_view.show()
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

@@ -1,18 +1,23 @@
"""Module to run a static test for the current config and see if it is valid."""
"""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
import bec_lib
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
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 SafeProperty, SafeSlot
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
READY_TO_TEST = False
@@ -28,306 +33,378 @@ except 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."""
UNKNOWN = 0 # colors.default
ERROR = 1 # colors.emergency
VALID = 2 # colors.highlight
CANT_CONNECT = 3 # colors.warning
CONNECTED = 4 # colors.success
PENDING = 0 # colors.default
VALID = 1 # colors.highlight
FAILED = 2 # colors.emergency
class DeviceValidationListItem(QtWidgets.QWidget):
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."""
status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus
# Signal emitted when device was validated with name, success, msg
device_validated = QtCore.Signal(str, str)
def __init__(self, device_name: str, device_config: dict, parent=None):
"""
Initialize the validation list item.
def __init__(
self,
device_config: dict[str, dict],
status: ValidationStatus,
status_icons: dict[ValidationStatus, QtGui.QPixmap],
validate_icon: QtGui.QPixmap,
parent=None,
static_device_test=None,
):
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)
if len(device_config.keys()) > 1:
logger.warning(
f"Multiple devices found for config: {list(device_config.keys())}, using first one"
)
self._static_device_test = static_device_test
self.device_name = list(device_config.keys())[0]
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.status: ValidationStatus = status
colors = get_accent_colors()
self._status_icon = status_icons
self._validate_icon = validate_icon
self.validation_msg = "Validation in progress..."
self._setup_ui()
self._update_status_indicator()
def _setup_ui(self):
"""Setup the UI for the list item."""
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
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()
# Device name label
self.name_label = QtWidgets.QLabel(self.device_name)
self.name_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.name_label)
def _start_spinner(self):
"""Start the spinner animation."""
self._spinner.start()
# Make sure status is on the right
layout.addStretch()
self.request_validation_button = QtWidgets.QPushButton("Validate")
self.request_validation_button.setIcon(self._validate_icon)
if self._static_device_test is None:
self.request_validation_button.setDisabled(True)
else:
self.request_validation_button.clicked.connect(self.on_request_validation)
# self.request_validation_button.setVisible(False) -> Hide it??
layout.addWidget(self.request_validation_button)
# Status indicator
self.status_indicator = QtWidgets.QLabel()
self._update_status_indicator()
layout.addWidget(self.status_indicator)
def _stop_spinner(self):
"""Stop the spinner animation."""
self._spinner.stop()
self._spinner.setVisible(False)
@SafeSlot()
def on_request_validation(self):
"""Handle validate button click."""
if self._static_device_test is None:
logger.warning("Static device test not available.")
return
self._static_device_test.config = self.device_config
# TODO logic if connect is allowed
ret = self._static_device_test.run_with_list_output(connect=False)[0]
if ret.success:
self.set_status(ValidationStatus.VALID)
else:
self.set_status(ValidationStatus.ERROR)
self.device_validated.emit(ret.name, ret.message)
def on_validation_restart(self):
"""Handle validation restart."""
self.validation_msg = ""
self._start_spinner()
self.setStyleSheet("") # Check if this works as expected
def _update_status_indicator(self):
"""Update the status indicator color based on validation status."""
self.status_indicator.setPixmap(self._status_icon[self.status])
def set_status(self, status: ValidationStatus):
"""Update the validation status."""
self.status = status
self._update_status_indicator()
self.status_changed.emit(self.status)
def get_status(self) -> ValidationStatus:
"""Get the current validation status."""
return self.status
@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 DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget):
class DMOphydTest(BECWidget, QtWidgets.QWidget):
"""Widget to test device configurations using ophyd devices."""
config_changed = QtCore.Signal(
dict, dict
) # Signal emitted when the device config changed, new_config, old_config
# 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._set_disabled()
static_device_test = None
self.setDisabled(True)
self.tester = None
else:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
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()
static_device_test = StaticDeviceTest(config_dict={})
self._static_device_test = static_device_test
self._device_config: dict[str, dict] = {}
self._main_layout = QtWidgets.QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(4)
self._main_layout.setSpacing(0)
# Setup icons
colors = get_accent_colors()
self._validate_icon = material_icon(
icon_name="play_arrow", color=colors.default, filled=True
)
self._status_icons = {
ValidationStatus.UNKNOWN: material_icon(
icon_name="circle", size=(12, 12), color=colors.default, filled=True
),
ValidationStatus.ERROR: material_icon(
icon_name="circle", size=(12, 12), color=colors.emergency, filled=True
),
ValidationStatus.VALID: material_icon(
icon_name="circle", size=(12, 12), color=colors.highlight, filled=True
),
ValidationStatus.CANT_CONNECT: material_icon(
icon_name="circle", size=(12, 12), color=colors.warning, filled=True
),
ValidationStatus.CONNECTED: material_icon(
icon_name="circle", size=(12, 12), color=colors.success, filled=True
),
}
self.setLayout(self._main_layout)
# splitter
# 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)
# Add custom list
self.setup_device_validation_list()
# Setup text box
self.setup_text_box()
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.config_changed.connect(self.on_config_updated)
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
@SafeSlot(list)
def on_device_config_update(self, config: list[dict]):
old_cfg = self._device_config
self._device_config = self._compile_device_config_list(config)
self.config_changed.emit(self._device_config, old_cfg)
def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]:
return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config}
@SafeSlot(dict, dict)
def on_config_updated(self, new_config: dict, old_config: dict):
"""Handle config updates and refresh the validation list."""
# Find differences for potential re-validation
diffs = self._find_diffs(new_config, old_config)
# Check diff first
for diff in diffs:
if not diff:
continue
if len(diff) > 1:
logger.warning(f"Multiple devices found in diff: {diff}, using first one")
name = list(diff.keys())[0]
if name in self.client.device_manager.devices:
status = ValidationStatus.CONNECTED
else:
status = ValidationStatus.UNKNOWN
if self.get_device_status(diff) is None:
self.add_device(diff, status)
else:
self.update_device_status(diff, status)
def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]:
"""
Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary.
@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:
new_config: The first dictionary to compare.
old_config: The second dictionary to compare.
device_configs (list[dict[str, Any]]): The updated device configurations.
"""
diffs = []
keys = set(new_config.keys()) | set(old_config.keys())
for k in keys:
if k not in old_config: # New device
diffs.append({k: new_config[k]})
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 k not in new_config: # Removed device
diffs.append({k: old_config[k]})
if name not in self._device_list_items:
continue
# Compare device config if exists in both
v1, v2 = old_config[k], new_config[k]
if isinstance(v1, dict) and isinstance(v2, dict):
if self._find_diffs(v2, v1): # recurse: something inside changed
diffs.append({k: new_config[k]})
elif v1 != v2:
diffs.append({k: new_config[k]})
return diffs
self._remove_list_item(name)
def setup_device_validation_list(self):
"""Setup the device validation list."""
# Create the custom validation list widget
self.validation_list = QtWidgets.QListWidget()
self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.splitter.addWidget(self.validation_list)
# self._main_layout.addWidget(self.validation_list)
def _add_device(self, name, cfg):
item = QtWidgets.QListWidgetItem(self._list_widget)
widget = ValidationListItem(device_name=name, device_config=cfg)
def setup_text_box(self):
"""Setup the text box for device validation messages."""
self.validation_text_box = QtWidgets.QTextEdit()
self.validation_text_box.setReadOnly(True)
self.splitter.addWidget(self.validation_text_box)
# self._main_layout.addWidget(self.validation_text_box)
# 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
@SafeSlot(str, str)
def on_device_validated(self, device_name: str, message: str):
"""Handle device validation results."""
text = f"Device {device_name} was validated. Message: {message}"
self.validation_text_box.setText(text)
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)
def _set_disabled(self) -> None:
"""Disable the full view"""
self.setDisabled(True)
# 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
def add_device(
self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN
# 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
):
"""Add a device to the validation list."""
# Create the custom widget
item_widget = DeviceValidationListItem(
device_config=device_config,
status=status,
status_icons=self._status_icons,
validate_icon=self._validate_icon,
static_device_test=self._static_device_test,
"""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
)
# Create a list widget item
list_item = QtWidgets.QListWidgetItem()
list_item.setSizeHint(item_widget.sizeHint())
for m in field_pat.finditer(raw_msg):
field = m.group("field")
rest = m.group("rest").rstrip()
lines.append(f"### {field}")
lines.append(rest)
# Add item to list and set custom widget
self.validation_list.addItem(list_item)
self.validation_list.setItemWidget(list_item, item_widget)
item_widget.device_validated.connect(self.on_device_validated)
return "\n".join(lines)
def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus):
"""Update the validation status for a specific device."""
for i in range(self.validation_list.count()):
item = self.validation_list.item(i)
widget = self.validation_list.itemWidget(item)
if (
isinstance(widget, DeviceValidationListItem)
and widget.device_config == device_config
):
widget.set_status(status)
break
def validation_running(self):
return self._device_list_items != {}
def clear_devices(self):
"""Clear all devices from the list."""
self.validation_list.clear()
@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 get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None:
"""Get the validation status for a specific device."""
for i in range(self.validation_list.count()):
item = self.validation_list.item(i)
widget = self.validation_list.itemWidget(item)
if (
isinstance(widget, DeviceValidationListItem)
and widget.device_config == device_config
):
return widget.get_status()
return None
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)
device_manager_ophyd_test = DeviceManagerOphydTest()
cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config()
cfg.append({"name": "Wrong_Device", "type": "test"})
device_manager_ophyd_test.on_device_config_update(cfg)
device_manager_ophyd_test.show()
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

@@ -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: str | None = "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

@@ -767,7 +767,7 @@ class MotorMap(PlotBase):
float: Motor initial position.
"""
entry = self.entry_validator.validate_signal(name, None)
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
init_position = round(float(self.dev[name].read(cached=True)[entry]["value"]), precision)
return init_position
def _sync_motor_map_selection_toolbar(self):

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

@@ -42,10 +42,15 @@ class CurveConfig(ConnectionConfig):
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
"solid", description="The style of the pen of the curve."
)
source: Literal["device", "dap", "custom"] = Field(
source: Literal["device", "dap", "custom", "history"] = Field(
"custom", description="The source of the curve."
)
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
scan_id: str | None = Field(None, description="Scan ID to be used when `source` is 'history'.")
scan_number: int | None = Field(
None, description="Scan index to be used when `source` is 'history'."
)
current_x_mode: str | None = Field(None, description="The current x mode of the history curve.")
parent_label: str | None = Field(
None, description="The label of the parent plot, only relevant for dap curves."
)
@@ -199,7 +204,7 @@ class Curve(BECConnector, pg.PlotDataItem):
Raises:
ValueError: If the source is not custom.
"""
if self.config.source == "custom":
if self.config.source in ["custom", "history"]:
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")

View File

@@ -5,7 +5,34 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QValidator
class ScanIndexValidator(QValidator):
"""Validator to allow only 'live' or integer scan numbers from an allowed set."""
def __init__(self, allowed_scans: set[int] | None = None, parent=None):
super().__init__(parent)
self.allowed_scans = allowed_scans or set()
def validate(self, input_str: str, pos: int):
# Accept empty or 'live'
if input_str == "" or input_str == "live":
return QValidator.State.Acceptable, input_str, pos
# Allow partial editing of "live"
if "live".startswith(input_str):
return QValidator.State.Intermediate, input_str, pos
# Accept integer only if present in the allowed set
if input_str.isdigit():
try:
num = int(input_str)
except ValueError:
return QValidator.State.Invalid, input_str, pos
if num in self.allowed_scans:
return QValidator.State.Acceptable, input_str, pos
return QValidator.State.Invalid, input_str, pos
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -93,8 +120,60 @@ class CurveRow(QTreeWidgetItem):
# Create columns 1..2, depending on source
self._init_source_ui()
# Create columns 3..6 (color, style, width, symbol)
self._init_scan_index_ui()
self._init_style_controls()
def _init_scan_index_ui(self):
"""Create the Scan # editable combobox in column 3."""
if self.source not in ("device", "history"):
return
self.scan_index_combo = QComboBox()
self.scan_index_combo.setEditable(True)
# Populate 'live' and all available history scan indices
self.scan_index_combo.addItem("live", None)
scan_number_list = []
scan_id_list = []
try:
history = getattr(self.curve_tree.client, "history", None)
if history is not None:
scan_number_list = getattr(history, "_scan_numbers", []) or []
scan_id_list = getattr(history, "_scan_ids", []) or []
except Exception as e:
logger.error(f"Cannot fetch scan numbers from BEC client: {e}")
# If scan numbers cannot be fetched, only provide 'live' option
scan_number_list = []
scan_id_list = []
# Restrict input to 'live' or valid scan numbers
allowed = set()
try:
allowed = set(int(n) for n in scan_number_list if isinstance(n, (int, str)))
except Exception:
allowed = set()
validator = ScanIndexValidator(allowed, self.scan_index_combo)
self.scan_index_combo.lineEdit().setValidator(validator)
# Add items: show scan numbers, store scan IDs as item data
if scan_number_list and scan_id_list and len(scan_number_list) == len(scan_id_list):
for num, sid in zip(scan_number_list, scan_id_list):
self.scan_index_combo.addItem(str(num), sid)
else:
logger.error("Scan number and ID lists are mismatched or empty.")
# Select current based on existing config
selected = False
if getattr(self.config, "scan_id", None): # scan_id matching only
for i in range(self.scan_index_combo.count()):
if self.scan_index_combo.itemData(i) == self.config.scan_id:
self.scan_index_combo.setCurrentIndex(i)
selected = True
break
if not selected:
self.scan_index_combo.setCurrentText("live")
self.tree.setItemWidget(self, 3, self.scan_index_combo)
def _init_actions(self):
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
self.actions_widget = QWidget()
@@ -116,7 +195,7 @@ class CurveRow(QTreeWidgetItem):
actions_layout.addWidget(self.delete_button)
# If device row, add "Add DAP" button
if self.source == "device":
if self.source in ("device", "history"):
self.add_dap_button = QToolButton()
analysis_icon = material_icon(
"monitoring",
@@ -134,7 +213,7 @@ class CurveRow(QTreeWidgetItem):
def _init_source_ui(self):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
if self.source in ("device", "history"):
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceComboBox(parent=self.tree)
self.device_edit.insertItem(0, "")
@@ -163,7 +242,6 @@ class CurveRow(QTreeWidgetItem):
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
else:
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
@@ -182,31 +260,31 @@ class CurveRow(QTreeWidgetItem):
self.tree.setItemWidget(self, 2, self.dap_combo)
def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
"""Create columns 4..7: color button, style combo, width spin, symbol spin."""
# Color in col 4
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.color_changed.connect(self._on_color_changed)
self.tree.setItemWidget(self, 3, self.color_button)
self.tree.setItemWidget(self, 4, self.color_button)
# Style in col 4
# Style in col 5
self.style_combo = QComboBox()
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
idx = self.style_combo.findText(self.config.pen_style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
self.tree.setItemWidget(self, 4, self.style_combo)
self.tree.setItemWidget(self, 5, self.style_combo)
# Pen width in col 5
# Pen width in col 6
self.width_spin = QSpinBox()
self.width_spin.setRange(1, 20)
self.width_spin.setValue(self.config.pen_width)
self.tree.setItemWidget(self, 5, self.width_spin)
self.tree.setItemWidget(self, 6, self.width_spin)
# Symbol size in col 6
# Symbol size in col 7
self.symbol_spin = QSpinBox()
self.symbol_spin.setRange(1, 20)
self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin)
self.tree.setItemWidget(self, 7, self.symbol_spin)
@SafeSlot(str, verify_sender=True)
def _on_color_changed(self, new_color: str):
@@ -220,8 +298,8 @@ class CurveRow(QTreeWidgetItem):
self.config.symbol_color = new_color
def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'."""
if self.source != "device":
"""Create a new DAP row as a child. Only valid if source is 'device' or 'history'."""
if self.source not in ("device", "history"):
return
curve_tree = self.tree.parent()
parent_label = self.config.label
@@ -299,7 +377,7 @@ class CurveRow(QTreeWidgetItem):
Returns:
dict: The serialized config based on the GUI state.
"""
if self.source == "device":
if self.source in ("device", "history"):
# Gather device name/entry
device_name = ""
device_entry = ""
@@ -320,8 +398,23 @@ class CurveRow(QTreeWidgetItem):
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
scan_combo_text = self.scan_index_combo.currentText()
if scan_combo_text == "live" or scan_combo_text == "":
self.config.scan_number = None
self.config.scan_id = None
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
if scan_combo_text.isdigit():
try:
scan_num = int(scan_combo_text)
except ValueError:
scan_num = None
self.config.scan_number = scan_num
self.config.scan_id = self.scan_index_combo.currentData()
self.config.source = "history"
# Label history curves with scan number suffix
if scan_num is not None:
self.config.label = f"{device_name}-{device_entry}-scan-{scan_num}"
else:
# DAP logic
parent_conf_dict = {}
@@ -454,10 +547,12 @@ class CurveTree(BECWidget, QWidget):
self.toolbar.show_bundles(["curve_tree"])
def _init_tree(self):
"""Initialize the QTreeWidget with 7 columns and compact widths."""
"""Initialize the QTreeWidget with 8 columns and compact widths."""
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
self.tree.setColumnCount(8)
self.tree.setHeaderLabels(
["Actions", "Name", "Entry", "Scan #", "Color", "Style", "Width", "Symbol"]
)
header = self.tree.header()
for idx in range(self.tree.columnCount()):
@@ -467,10 +562,10 @@ class CurveTree(BECWidget, QWidget):
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(4, 70)
self.tree.setColumnWidth(5, 80)
self.tree.setColumnWidth(6, 50)
self.tree.setColumnWidth(7, 50)
self.layout.addWidget(self.tree)
@@ -594,9 +689,9 @@ class CurveTree(BECWidget, QWidget):
self.tree.clear()
self.all_items = []
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
top_curves = [c for c in self.waveform.curves if c.config.source in ("device", "history")]
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
for dev in device_curves:
for dev in top_curves:
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:

View File

@@ -8,6 +8,7 @@ import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.scan_data_container import ScanDataContainer
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtWidgets import (
@@ -35,6 +36,9 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
logger = bec_logger.logger
@@ -166,6 +170,7 @@ class Waveform(PlotBase):
# Curve data
self._sync_curves = []
self._async_curves = []
self._history_curves = []
self._slice_index = None
self._dap_curves = []
self._mode = None
@@ -182,12 +187,14 @@ class Waveform(PlotBase):
"readout_priority": None,
"label_suffix": "",
}
self._current_x_device: tuple[str, str] | None = None
# Specific GUI elements
self._init_roi_manager()
self.dap_summary = None
self.dap_summary_dialog = None
self._add_fit_parameters_popup()
self.scan_history_dialog = None
self._add_waveform_specific_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self.curve_settings_dialog = None
@@ -255,7 +262,7 @@ class Waveform(PlotBase):
super().add_side_menus()
self._add_dap_summary_side_menu()
def _add_fit_parameters_popup(self):
def _add_waveform_specific_popup(self):
"""
Add popups to the Waveform widget.
"""
@@ -265,11 +272,24 @@ class Waveform(PlotBase):
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
),
)
self.toolbar.components.add_safe(
"scan_history",
MaterialIconAction(
icon_name="manage_search",
tooltip="Open Scan History browser",
checkable=True,
parent=self,
),
)
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
self.toolbar.get_bundle("axis_popup").add_action("scan_history")
self.toolbar.components.get_action("fit_params").action.triggered.connect(
self.show_dap_summary_popup
)
self.toolbar.components.get_action("scan_history").action.triggered.connect(
self.show_scan_history_popup
)
@SafeSlot()
def _reset_view(self):
@@ -417,6 +437,47 @@ class Waveform(PlotBase):
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
self._roi_manager.toggle_roi(False)
################################################################################
# Scan History browser popup
# TODO this is so far quick implementation just as popup, we should make scan history also standalone widget later
def show_scan_history_popup(self):
"""
Show the scan history popup.
"""
scan_history_action = self.toolbar.components.get_action("scan_history").action
if self.scan_history_dialog is None or not self.scan_history_dialog.isVisible():
self.scan_history_widget = ScanHistoryBrowser(parent=self)
self.scan_history_dialog = QDialog(modal=False)
self.scan_history_dialog.setWindowTitle(f"{self.object_name} - Scan History Browser")
self.scan_history_dialog.layout = QVBoxLayout(self.scan_history_dialog)
self.scan_history_dialog.layout.addWidget(self.scan_history_widget)
self.scan_history_widget.scan_history_device_viewer.request_history_plot.connect(
lambda scan_id, device_name, signal_name: self.plot(
y_name=device_name, y_entry=signal_name, scan_id=scan_id
)
)
self.scan_history_dialog.finished.connect(self._scan_history_closed)
self.scan_history_dialog.show()
self.scan_history_dialog.resize(780, 320)
scan_history_action.setChecked(True)
else:
# If already open, bring it to the front
self.scan_history_dialog.raise_()
self.scan_history_dialog.activateWindow()
scan_history_action.setChecked(True) # keep it toggle
def _scan_history_closed(self):
"""
Slot for when the scan history dialog is closed.
"""
if self.scan_history_dialog is None:
return
self.scan_history_widget.close()
self.scan_history_widget.deleteLater()
self.scan_history_dialog.deleteLater()
self.scan_history_dialog = None
self.toolbar.components.get_action("scan_history").action.setChecked(False)
################################################################################
# Dap Summary
@@ -506,7 +567,11 @@ class Waveform(PlotBase):
self.x_axis_mode["name"] = value
if value not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(value, None)
self._current_x_device = (value, self.x_axis_mode["entry"])
self._switch_x_axis_item(mode=value)
self._current_x_device = None
self._refresh_history_curves()
self._update_curve_visibility()
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
@@ -534,6 +599,8 @@ class Waveform(PlotBase):
return
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
self._switch_x_axis_item(mode="device")
self._refresh_history_curves()
self._update_curve_visibility()
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
@@ -674,6 +741,8 @@ class Waveform(PlotBase):
color: str | None = None,
label: str | None = None,
dap: str | None = None,
scan_id: str | None = None,
scan_number: int | None = None,
**kwargs,
) -> Curve:
"""
@@ -696,6 +765,10 @@ class Waveform(PlotBase):
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
Returns:
Curve: The curve object.
@@ -765,6 +838,8 @@ class Waveform(PlotBase):
label=label,
color=color,
source=source,
scan_id=scan_id,
scan_number=scan_number,
**kwargs,
)
@@ -772,6 +847,9 @@ class Waveform(PlotBase):
if source == "device":
config.signal = DeviceSignal(name=y_name, entry=y_entry)
if scan_id is not None or scan_number is not None:
config.source = "history"
# CREATE THE CURVE
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
@@ -810,7 +888,7 @@ class Waveform(PlotBase):
device_curve = self._find_curve_by_label(device_label)
if not device_curve:
raise ValueError(f"No existing curve found with label '{device_label}'.")
if device_curve.config.source != "device":
if device_curve.config.source not in ("device", "history"):
raise ValueError(
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
)
@@ -819,7 +897,7 @@ class Waveform(PlotBase):
dev_entry = device_curve.config.signal.entry
# 2) Build a label for the new DAP curve
dap_label = f"{dev_name}-{dev_entry}-{dap_name}"
dap_label = f"{device_label}-{dap_name}"
# 3) Possibly raise if the DAP curve already exists
if self._check_curve_id(dap_label):
@@ -872,7 +950,23 @@ class Waveform(PlotBase):
ValueError: If a duplicate curve label/config is found, or if
custom data is missing for `source='custom'`.
"""
scan_item: ScanDataContainer | None = None
if config.source == "history":
scan_item = self.get_history_scan_item(
scan_id=config.scan_id, scan_index=config.scan_number
)
if scan_item is None:
raise ValueError(
f"Could not find scan item for history curve '{config.label}' with scan_id='{config.scan_id}' and scan_number='{config.scan_number}'."
)
config.scan_id = scan_item.metadata["bec"]["scan_id"]
config.scan_number = scan_item.metadata["bec"]["scan_number"]
label = config.label
if config.source == "history":
label = f"{config.signal.name}-{config.signal.entry}-scan-{config.scan_number}"
config.label = label
if not label:
# Fallback label
label = WidgetContainerUtils.generate_unique_name(
@@ -894,7 +988,7 @@ class Waveform(PlotBase):
raise ValueError("For 'custom' curves, x_data and y_data must be provided.")
# Actually create the Curve item
curve = self._add_curve_object(name=label, config=config)
curve = self._add_curve_object(name=label, config=config, scan_item=scan_item)
# If custom => set initial data
if config.source == "custom" and x_data is not None and y_data is not None:
@@ -911,6 +1005,8 @@ class Waveform(PlotBase):
self.setup_dap_for_scan()
self.roi_enable.emit(True) # Enable the ROI toolbar action
self.request_dap() # Request DAP update directly without blocking proxy
if config.source == "history":
self._history_curves.append(curve)
QTimer.singleShot(
150, self.auto_range
@@ -918,22 +1014,182 @@ class Waveform(PlotBase):
return curve
def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:
def _add_curve_object(
self, name: str, config: CurveConfig, scan_item: ScanDataContainer | None = None
) -> Curve | None:
"""
Low-level creation of the PlotDataItem (Curve) from a `CurveConfig`.
Args:
name (str): The name/label of the curve.
config (CurveConfig): Configuration model describing the curve.
scan_item (ScanDataContainer | None): Optional scan item for history curves.
Returns:
Curve: The newly created curve object, added to the plot.
"""
curve = Curve(config=config, name=name, parent_item=self)
self.plot_item.addItem(curve)
if scan_item is not None:
self._fetch_history_data_for_curve(curve, scan_item)
self._categorise_device_curves()
curve.visibleChanged.connect(self._refresh_crosshair_markers)
curve.visibleChanged.connect(self.auto_range)
return curve
def _fetch_history_data_for_curve(
self, curve: Curve, scan_item: ScanDataContainer
) -> Curve | None:
# Check if the data are already set
device = curve.config.signal.name
entry = curve.config.signal.entry
all_devices_used = getattr(
getattr(scan_item, "_msg", None), "stored_data_info", None
) or getattr(scan_item, "stored_data_info", None)
if all_devices_used is None:
curve.remove()
raise ValueError(
f"No stored data info found in scan item ID:{curve.config.scan_id} for curve '{curve.name()}'. "
f"Upgrade BEC to the latest version."
)
# 1. get y data
x_data, y_data = None, None
if device not in all_devices_used:
raise ValueError(f"Device '{device}' not found in scan item ID:{curve.config.scan_id}.")
if entry not in all_devices_used[device]:
raise ValueError(
f"Entry '{entry}' not found in device '{device}' in scan item ID:{curve.config.scan_id}."
)
y_shape = all_devices_used.get(device).get(entry).shape[0]
# Determine X-axis data
if self.x_axis_mode["name"] == "index":
x_data = np.arange(y_shape)
curve.config.current_x_mode = "index"
self._update_x_label_suffix(" (index)")
elif self.x_axis_mode["name"] == "timestamp":
y_device = scan_item.devices.get(device)
x_data = y_device.get(entry).read().get("timestamp")
curve.config.current_x_mode = "timestamp"
self._update_x_label_suffix(" (timestamp)")
elif self.x_axis_mode["name"] not in ("index", "timestamp", "auto"): # Custom device mode
if self.x_axis_mode["name"] not in all_devices_used:
logger.warning(
f"Custom device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_entry_custom = self.x_axis_mode.get("entry")
if x_entry_custom is None:
x_entry_custom = self.entry_validator.validate_signal(
self.x_axis_mode["name"], None
)
if x_entry_custom not in all_devices_used[self.x_axis_mode["name"]]:
logger.warning(
f"Custom entry '{x_entry_custom}' for device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_shape = (
scan_item._msg.stored_data_info.get(self.x_axis_mode["name"])
.get(x_entry_custom)
.shape[0]
)
if x_shape != y_shape:
logger.warning(
f"Shape mismatch for x data '{x_shape}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_device = scan_item.devices.get(self.x_axis_mode["name"])
x_data = x_device.get(x_entry_custom).read().get("value")
curve.config.current_x_mode = self.x_axis_mode["name"]
self._update_x_label_suffix(f" (custom: {self.x_axis_mode['name']}-{x_entry_custom})")
elif self.x_axis_mode["name"] == "auto":
if (
self._current_x_device is None
): # Scenario where no x device is set yet, because there was no live scan done in this widget yet
# If no current x device, use the first motor from scan item
scan_motors = self._ensure_str_list(
scan_item.metadata.get("bec").get("scan_report_devices")
)
if not scan_motors: # scan was done without reported motor from whatever reason
x_data = np.arange(y_shape) # Fallback to index
y_data = scan_item.devices.get(device).get(entry).read().get("value")
curve.set_data(x=x_data, y=y_data)
self._update_x_label_suffix(" (auto: index)")
return curve
x_entry = self.entry_validator.validate_signal(scan_motors[0], None)
if x_entry not in all_devices_used.get(scan_motors[0], {}):
logger.warning(
f"Auto x entry '{x_entry}' for device '{scan_motors[0]}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
if y_shape != all_devices_used.get(scan_motors[0]).get(x_entry, {}).shape[0]:
logger.warning(
f"Shape mismatch for x data '{all_devices_used.get(scan_motors[0]).get(x_entry, {}).get('shape', [0])[0]}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_data = scan_item.devices.get(scan_motors[0]).get(x_entry).read().get("value")
self._current_x_device = (scan_motors[0], x_entry)
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
curve.config.current_x_mode = "auto"
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
else: # Scan in auto mode was done and live scan already set the current x device
if self._current_x_device[0] not in all_devices_used:
logger.warning(
f"Auto x data for device '{self._current_x_device[0]}' "
f"and entry '{self._current_x_device[1]}'"
f" not found in scan item of the history curve {curve.name()}."
)
curve.setVisible(False)
return
x_device = scan_item.devices.get(self._current_x_device[0])
x_data = x_device.get(self._current_x_device[1]).read().get("value")
curve.config.current_x_mode = "auto"
self._update_x_label_suffix(
f" (auto: {self._current_x_device[0]}-{self._current_x_device[1]})"
)
if x_data is None:
logger.warning(
f"X data for curve '{curve.name()}' could not be determined. "
f"Check if the x_mode '{self.x_axis_mode['name']}' is valid for the scan item."
)
curve.setVisible(False)
return
if y_data is None:
y_data = scan_item.devices.get(device).get(entry).read().get("value")
if y_data is None:
logger.warning(
f"Y data for curve '{curve.name()}' could not be determined. "
f"Check if the device '{device}' and entry '{entry}' are valid for the scan item."
)
curve.setVisible(False)
return
curve.set_data(x=x_data, y=y_data)
return curve
def _refresh_history_curves(self):
for curve in self._history_curves:
scan_item = self.get_history_scan_item(
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
)
if scan_item is not None:
self._fetch_history_data_for_curve(curve, scan_item)
else:
logger.warning(f"Scan item for curve {curve.name()} not found.")
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.
@@ -961,7 +1217,42 @@ class Waveform(PlotBase):
Clear all data from the plot widget, but keep the curve references.
"""
for c in self.curves:
c.clear_data()
if c.config.source != "history":
c.clear_data()
# X-axis compatibility helpers
def _is_curve_compatible(self, curve: Curve) -> bool:
"""
Return True when *curve* can be shown with the current x-axis mode.
- index, timestamp are always compatible.
- For history curves we check whether the requested motor
(self.x_axis_mode["name"]) exists in the cached
history_data_buffer["x"] dictionary.
- DAP is done by checking if the parent curve is visible.
- Device curves are fetched by update sync/async curves, which solves the compatibility there.
"""
mode = self.x_axis_mode.get("name", "index")
if mode in ("index", "timestamp"): # always compatible - wild west mode
return True
if curve.config.source == "history":
scan_item = self.get_history_scan_item(
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
)
curve = self._fetch_history_data_for_curve(curve, scan_item)
if curve is None:
return False
if curve.config.source == "dap":
parent_curve = self._find_curve_by_label(curve.config.parent_label)
if parent_curve.isVisible():
return True
return False # DAP curve is not compatible if parent curve is not visible
return True
def _update_curve_visibility(self) -> None:
"""Show or hide curves according to `_is_curve_compatible`."""
for c in self.curves:
c.setVisible(self._is_curve_compatible(c))
def clear_all(self):
"""
@@ -1118,12 +1409,13 @@ 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
self._slice_index = None # Reset the slice index
self._update_curve_visibility()
self._mode = self._categorise_device_curves()
# First trigger to sync and async data
@@ -1201,7 +1493,7 @@ class Waveform(PlotBase):
device_data = entry_obj.read()["value"] if entry_obj else None
x_data = self._get_x_data(device_name, device_entry)
if x_data is not None:
if len(x_data) == 1:
if np.isscalar(x_data):
self.clear_data()
return
if device_data is not None and x_data is not None:
@@ -1609,6 +1901,7 @@ class Waveform(PlotBase):
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else [0]
new_suffix = f" (custom: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
@@ -1623,11 +1916,13 @@ class Waveform(PlotBase):
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
x_data = timestamps
new_suffix = " (timestamp)"
self._current_x_device = None
# 3 User wants index
if self.x_axis_mode["name"] == "index":
x_data = None
new_suffix = " (index)"
self._current_x_device = None
# 4 Best effort automatic mode
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
@@ -1635,6 +1930,7 @@ class Waveform(PlotBase):
if len(self._async_curves) > 0:
x_data = None
new_suffix = " (auto: index)"
self._current_x_device = None
# 4.2 If there are sync curves, use the first device from the scan report
else:
try:
@@ -1657,6 +1953,7 @@ class Waveform(PlotBase):
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
self._update_x_label_suffix(new_suffix)
return x_data
@@ -1759,49 +2056,83 @@ class Waveform(PlotBase):
logger.info(f"Scan {self.scan_id} => mode={self._mode}")
return mode
@SafeSlot(int)
@SafeSlot(str)
@SafeSlot()
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
def get_history_scan_item(
self, scan_index: int = None, scan_id: str = None
) -> ScanDataContainer | None:
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Get scan item from history based on scan_id or scan_index.
If both are provided, scan_id takes precedence and the resolved scan_number
will be read from the fetched item.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
scan_id (str, optional): ScanID of the scan to fetch. Defaults to None.
scan_index (int, optional): Index (scan number) of the scan to fetch. Defaults to None.
Returns:
ScanDataContainer | None: The fetched scan item or None if no item was found.
"""
if scan_index is not None and scan_id is not None:
raise ValueError("Only one of scan_id or scan_index can be provided.")
scan_index = None # Prefer scan_id when both are given
if scan_index is None and scan_id is None:
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
logger.warning("Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self._emit_signal_update()
return
return self.client.history.get_by_scan_id(scan_id)
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self._emit_signal_update()
return
return None
return scan_item
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
logger.info("No scans executed so far. Cannot fetch scan history.")
return None
# check if scan_index is negative, then fetch it just from the list from the end
if int(scan_index) < 0:
return self.client.history[scan_index]
scan_item = self.client.history.get_by_scan_number(scan_index)
if scan_item is None:
logger.warning(f"Scan with scan_number {scan_index} not found in history.")
return None
if isinstance(scan_item, list):
if len(scan_item) > 1:
logger.warning(
f"Multiple scans found with scan_number {scan_index}. Returning the latest one."
)
scan_item = scan_item[-1]
return scan_item
@SafeSlot(int)
@SafeSlot(str)
@SafeSlot()
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
"""
self.scan_item = self.get_history_scan_item(scan_index=scan_index, scan_id=scan_id)
if self.scan_item is None:
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
if scan_id is not None:
self.scan_id = scan_id
else:
# If scan_number was used, set the scan_id from the fetched item
if hasattr(self.scan_item, "metadata"):
self.scan_id = self.scan_item.metadata["bec"]["scan_id"]
else:
self.scan_id = self.scan_item.scan_id
self._emit_signal_update()
@@ -2032,6 +2363,9 @@ class Waveform(PlotBase):
if self.dap_summary_dialog is not None:
self.dap_summary_dialog.reject()
self.dap_summary_dialog = None
if self.scan_history_dialog is not None:
self.scan_history_dialog.reject()
self.scan_history_dialog = None
super().cleanup()

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
@@ -21,7 +19,7 @@ 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
@@ -109,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:
@@ -134,7 +132,7 @@ class DeviceBrowser(BECWidget, QWidget):
def _add_item_to_list(self, device: str, device_obj):
device_item = self.dev_list.add_item(
_, device_item = self.dev_list.add_item(
id=device,
parent=self,
device=device,

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,
@@ -91,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

@@ -38,8 +38,8 @@ class SignalDisplay(BECWidget, QWidget):
@SafeSlot()
def _refresh(self):
if (dev := self.dev.get(self.device)) is not None:
dev.read()
dev.read_configuration()
dev.read(cached=True)
dev.read_configuration(cached=True)
def _add_refresh_button(self):
button_holder = QWidget()

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,
@@ -273,7 +272,9 @@ class SignalLabel(BECWidget, QWidget):
if not isinstance(self._device_obj, Device | Signal):
self._value, self._units = "__", ""
return
reading = (self._device_obj.read() or {}) | (self._device_obj.read_configuration() or {})
reading = (self._device_obj.read(cached=True) or {}) | (
self._device_obj.read_configuration(cached=True) or {}
)
value = reading.get(self._signal_key, {}).get("value")
if value is None:
self._value, self._units = "__", ""
@@ -481,6 +482,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:
```
````

View File

@@ -4,7 +4,7 @@
````{tab} Overview
The [`Scan Control`](/api_reference/_autosummary/bec_widgets.cli.client.ScanControl) widget provides a graphical user interface (GUI) to manage various scan operations in a BEC environment. It is designed to interact with the BEC server, enabling users to start and stop scans. The widget automatically creates the necessary input form based on the scan's signature and gui_config, making it highly adaptable to different scanning processes.
The `ScanControl` widget provides a graphical user interface (GUI) to manage various scan operations in a BEC environment. It is designed to interact with the BEC server, enabling users to start and stop scans. The widget automatically creates the necessary input form based on the scan's signature and gui_config, making it highly adaptable to different scanning processes.
## Key Features:
- **Automatic Interface Generation**: Automatically generates a control interface based on scan signatures and `gui_config`.
@@ -59,6 +59,8 @@ scan_control = dock_area.new().new(gui.available_widgets.ScanControl)
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ScanControl.rst
.. autoclass:: bec_widgets.cli.client.ScanControl
:members:
:show-inheritance:
```
````

View File

@@ -34,6 +34,8 @@ The ScatterWaveform widget only plots the data points if both x and y axis motor
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ScatterWaveform.rst
.. autoclass:: bec_widgets.cli.client.ScatterWaveform
:members:
:show-inheritance:
```
````

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