1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

53 Commits

Author SHA1 Message Date
semantic-release
4d5daf6557 2.12.0
Automatically generated by python-semantic-release
2025-06-04 19:51:34 +00:00
718116afc3 fix: exclude metadata from RPC 2025-06-04 21:50:54 +02:00
2dda58f7d2 feat: add clickable label util 2025-06-04 21:50:54 +02:00
594912136e fix: grid formatting in TypedForm 2025-06-04 21:50:54 +02:00
5188b38c86 feat: (#493) device browser to display config 2025-06-04 21:50:54 +02:00
a10e6f7820 fix: make generate plugin robust to multiline init
instead of str.find, use multiline regex with whitespace
2025-06-04 21:50:54 +02:00
e0e26c205b fix(device browser): mocks and utils for tests 2025-06-04 21:50:54 +02:00
92d1d6435d feat: (#493) add dict to dynamic form types 2025-06-04 21:50:54 +02:00
a25c1a8039 feat: (#493) add helpers to dynamic form widgets 2025-06-04 21:50:54 +02:00
semantic-release
fed068f857 2.11.0
Automatically generated by python-semantic-release
2025-06-04 12:12:27 +00:00
7eb2f54e0e fix(image layer): add layer main if it does not exist 2025-06-04 14:11:46 +02:00
92b89e7275 refactor(image_base): move default color map to image layer 2025-06-04 14:11:46 +02:00
a4f3117941 refactor(image_item): emit object name with removed signal 2025-06-04 14:11:46 +02:00
3e789ca35b refactor(image_item): removed outdated image item config 2025-06-04 14:11:46 +02:00
92dade0950 refactor(image_base): renamed layers to layer_manager and added public methods for accessing the layer manager 2025-06-04 14:11:46 +02:00
4a343b2041 feat(image_layer): add default name for image layers 2025-06-04 14:11:46 +02:00
c2b0c8c433 refactor(image): move image item creation to layer manager 2025-06-04 14:11:46 +02:00
8a299a8268 refactor(image): disconnect when layer is removed 2025-06-04 14:11:46 +02:00
99ecf6a18f refactor(image): removed access to image item config 2025-06-04 14:11:46 +02:00
4c0bd977fc fix(image_item): do not disconnect the monitor from within the image item 2025-06-04 14:11:46 +02:00
7c47505c5a test: improve error message for widgets that are not properly cleaned up 2025-06-04 14:11:46 +02:00
e211e4d716 fix(image item): propagate remove call to parent class 2025-06-04 14:11:46 +02:00
10f292def9 refactor(image): introduce image base and image layer; rename vrange to v_range 2025-06-04 14:11:46 +02:00
semantic-release
d111ded737 2.10.3
Automatically generated by python-semantic-release
2025-06-04 09:00:59 +00:00
2d0ed94f3f fix(color_button_native): popup logic to choose color moved to ColorButtonNative 2025-06-04 11:00:21 +02:00
semantic-release
f68f072da3 2.10.2
Automatically generated by python-semantic-release
2025-06-03 11:57:23 +00:00
1df6c1925b fix: remove unnecessary PySide imports 2025-06-03 13:56:35 +02:00
6b939ac34d ci: check for disallowed imports from PySide 2025-06-03 13:56:35 +02:00
semantic-release
6bcf20af07 2.10.1
Automatically generated by python-semantic-release
2025-06-02 18:37:30 +00:00
a64cf0dd87 build: pyte removed from dependencies 2025-06-02 20:36:51 +02:00
cd4e90a79f fix(console): qt console widget deleted 2025-06-02 20:36:51 +02:00
semantic-release
49a96a18d6 2.10.0
Automatically generated by python-semantic-release
2025-06-02 13:51:20 +00:00
2b4454a291 ci: fix artifact version 2025-06-02 15:50:41 +02:00
d12bd9fe1a ci: add job logs to e2e test 2025-06-02 15:50:41 +02:00
d0c1ac0cf5 feat(waveform): large async dataset warning popup 2025-06-02 15:50:41 +02:00
f90150d1c7 fix(waveform): waveform only update async data when scan is currently running 2025-06-02 15:50:41 +02:00
semantic-release
c684b6c230 2.9.2
Automatically generated by python-semantic-release
2025-05-30 13:03:46 +00:00
91126168b6 fix(log_panel): removed lambda callback method 2025-05-30 15:03:08 +02:00
7322cd194f fix: move log panel to bec connector and add rate limiter 2025-05-30 15:03:08 +02:00
d9dc60ee99 fix: logpanel error cycle 2025-05-30 15:03:08 +02:00
semantic-release
e4cd4891ad 2.9.1
Automatically generated by python-semantic-release
2025-05-30 11:27:23 +00:00
12f8c82eb5 fix: make registry update log message debug level 2025-05-30 13:26:40 +02:00
semantic-release
f46ffb14e1 2.9.0
Automatically generated by python-semantic-release
2025-05-30 11:14:35 +00:00
2b9919bb34 docs: add usage docs for signal label widget 2025-05-30 13:13:55 +02:00
822e7d06ff feat: (#569) add signal label widget
add a widget which shows the current value of a signal from BEC.
configurable with many properties in designer. intended for use mainly
in static GUIs.
2025-05-30 13:13:55 +02:00
91195ae0fd fix(DeviceSignalInput): improve robustness
use set for storing filter properties to allow multiple set to true or
false
2025-05-30 13:13:55 +02:00
a6c5c21afa style: typing in bec_dispatcher 2025-05-30 13:13:55 +02:00
semantic-release
ff06954cb7 2.8.4
Automatically generated by python-semantic-release
2025-05-30 11:01:06 +00:00
c8128faf79 fix(crosshair): label decimal precision is dynamically scaled with the plot zoom; API of all affected widgets adjusted; option added to PlotBase; closes #637 2025-05-30 13:00:18 +02:00
semantic-release
6b65a94c81 2.8.3
Automatically generated by python-semantic-release
2025-05-30 09:03:15 +00:00
bf172b8431 fix: guard plugin repo import in e2e test 2025-05-30 11:02:14 +02:00
05329ab50f test(e2e): add tests involving plugin repo 2025-05-28 20:39:51 +02:00
b225a7cc90 refactor: store modules with widget search 2025-05-28 13:05:28 +02:00
80 changed files with 4529 additions and 2324 deletions

View File

@@ -12,6 +12,7 @@ jobs:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
@@ -39,10 +40,19 @@ jobs:
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
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
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- name: Upload logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
retention-days: 7

View File

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

View File

@@ -1,6 +1,222 @@
# CHANGELOG
## v2.12.0 (2025-06-04)
### Bug Fixes
- Exclude metadata from RPC
([`718116a`](https://github.com/bec-project/bec_widgets/commit/718116afc3a724658c4cd57b76e93249a66a9ebd))
- Grid formatting in TypedForm
([`5949121`](https://github.com/bec-project/bec_widgets/commit/594912136e2118de1a4de5213c2f668952f28a84))
- Make generate plugin robust to multiline init
([`a10e6f7`](https://github.com/bec-project/bec_widgets/commit/a10e6f7820309d590e832f2bca44ca1db8ef72a1))
instead of str.find, use multiline regex with whitespace
- **device browser**: Mocks and utils for tests
([`e0e26c2`](https://github.com/bec-project/bec_widgets/commit/e0e26c205bf930d680e01910f87489decc7fbcdb))
### Features
- (#493) add dict to dynamic form types
([`92d1d64`](https://github.com/bec-project/bec_widgets/commit/92d1d6435d6e8c05851804eb76605a4abeec01bb))
- (#493) add helpers to dynamic form widgets
([`a25c1a8`](https://github.com/bec-project/bec_widgets/commit/a25c1a8039078c92789b717b3f8a553c75814c33))
- (#493) device browser to display config
([`5188b38`](https://github.com/bec-project/bec_widgets/commit/5188b38c86f543d2abc742411b64fa127c6c0c16))
- Add clickable label util
([`2dda58f`](https://github.com/bec-project/bec_widgets/commit/2dda58f7d2adf1f41c6ce4fad02d55bd9aa200fa))
## v2.11.0 (2025-06-04)
### Bug Fixes
- **image item**: Propagate remove call to parent class
([`e211e4d`](https://github.com/bec-project/bec_widgets/commit/e211e4d7161cc4fc4b2f7cd18f058e070f5b4b7a))
- **image layer**: Add layer main if it does not exist
([`7eb2f54`](https://github.com/bec-project/bec_widgets/commit/7eb2f54e0ed556e0c30a4e14ded75e32dcf3d531))
- **image_item**: Do not disconnect the monitor from within the image item
([`4c0bd97`](https://github.com/bec-project/bec_widgets/commit/4c0bd977fc2b82680bbace763f5ffb19ed664f72))
### Features
- **image_layer**: Add default name for image layers
([`4a343b2`](https://github.com/bec-project/bec_widgets/commit/4a343b204112c53e593e9bb43642d21f268dfa85))
### Refactoring
- **image**: Disconnect when layer is removed
([`8a299a8`](https://github.com/bec-project/bec_widgets/commit/8a299a8268f3c21bbdc6629ad1f1f50a0aa0948b))
- **image**: Introduce image base and image layer; rename vrange to v_range
([`10f292d`](https://github.com/bec-project/bec_widgets/commit/10f292def9d1551bca0d8f63c0a94799c08ff507))
- **image**: Move image item creation to layer manager
([`c2b0c8c`](https://github.com/bec-project/bec_widgets/commit/c2b0c8c4336302ec4a7807c31b3f3b78a413c1aa))
- **image**: Removed access to image item config
([`99ecf6a`](https://github.com/bec-project/bec_widgets/commit/99ecf6a18f2e87d68f3de3abf56d97f7e6467912))
- **image_base**: Move default color map to image layer
([`92b89e7`](https://github.com/bec-project/bec_widgets/commit/92b89e72750fc0ab72ea51f865032133c49a7f18))
- **image_base**: Renamed layers to layer_manager and added public methods for accessing the layer
manager
([`92dade0`](https://github.com/bec-project/bec_widgets/commit/92dade09508ff3940e0b5dc99917302d61b03bc8))
- **image_item**: Emit object name with removed signal
([`a4f3117`](https://github.com/bec-project/bec_widgets/commit/a4f311794132c6c24370cb2f5b5e0725b12587fd))
- **image_item**: Removed outdated image item config
([`3e789ca`](https://github.com/bec-project/bec_widgets/commit/3e789ca35b6d0cf2d8ae9677bc65b7f0ca4eabc7))
### Testing
- Improve error message for widgets that are not properly cleaned up
([`7c47505`](https://github.com/bec-project/bec_widgets/commit/7c47505c5a147885ca2e854e13c1eb3fddaf5489))
## v2.10.3 (2025-06-04)
### Bug Fixes
- **color_button_native**: Popup logic to choose color moved to ColorButtonNative
([`2d0ed94`](https://github.com/bec-project/bec_widgets/commit/2d0ed94f3feb38dfc9645f2c3b9d6a06b92637bb))
## v2.10.2 (2025-06-03)
### Bug Fixes
- Remove unnecessary PySide imports
([`1df6c19`](https://github.com/bec-project/bec_widgets/commit/1df6c1925b6ec88df8d7a1a5a79a5ddc6b1161b5))
### Continuous Integration
- Check for disallowed imports from PySide
([`6b939ac`](https://github.com/bec-project/bec_widgets/commit/6b939ac34d01cdbb0e8e32a0bd4e56cad032e75b))
## v2.10.1 (2025-06-02)
### Bug Fixes
- **console**: Qt console widget deleted
([`cd4e90a`](https://github.com/bec-project/bec_widgets/commit/cd4e90a79fcdbc96f4ec23db22375d05a48731db))
### Build System
- Pyte removed from dependencies
([`a64cf0d`](https://github.com/bec-project/bec_widgets/commit/a64cf0dd871c1419e02d3803c74cc45966baac19))
## v2.10.0 (2025-06-02)
### Bug Fixes
- **waveform**: Waveform only update async data when scan is currently running
([`f90150d`](https://github.com/bec-project/bec_widgets/commit/f90150d1c708331d4ee78f82ebf5ef23cd81fd17))
### Continuous Integration
- Add job logs to e2e test
([`d12bd9f`](https://github.com/bec-project/bec_widgets/commit/d12bd9fe1a010babc94dc86405d1b75a2b07534c))
- Fix artifact version
([`2b4454a`](https://github.com/bec-project/bec_widgets/commit/2b4454a291bc69399ddd08780c44e1339825fb36))
### Features
- **waveform**: Large async dataset warning popup
([`d0c1ac0`](https://github.com/bec-project/bec_widgets/commit/d0c1ac0cf5d421d14c9e050ccf5832cd30ca0764))
## v2.9.2 (2025-05-30)
### Bug Fixes
- Logpanel error cycle
([`d9dc60e`](https://github.com/bec-project/bec_widgets/commit/d9dc60ee9974e2e6e6005378cc17ef088a4ded2c))
- Move log panel to bec connector and add rate limiter
([`7322cd1`](https://github.com/bec-project/bec_widgets/commit/7322cd194fcf7f56d41c86ecbcd97a5d8bd60c3e))
- **log_panel**: Removed lambda callback method
([`9112616`](https://github.com/bec-project/bec_widgets/commit/91126168b62f3e1623521ceb205dd854287cfef7))
## v2.9.1 (2025-05-30)
### Bug Fixes
- Make registry update log message debug level
([`12f8c82`](https://github.com/bec-project/bec_widgets/commit/12f8c82eb59ed6a7273b57126efe340bf37b65cc))
## v2.9.0 (2025-05-30)
### Bug Fixes
- **DeviceSignalInput**: Improve robustness
([`91195ae`](https://github.com/bec-project/bec_widgets/commit/91195ae0fdf024daf2daaa4ea2963992b4e40e04))
use set for storing filter properties to allow multiple set to true or false
### Code Style
- Typing in bec_dispatcher
([`a6c5c21`](https://github.com/bec-project/bec_widgets/commit/a6c5c21afaa6dcf33ce71027e8730354ee34e3b4))
### Documentation
- Add usage docs for signal label widget
([`2b9919b`](https://github.com/bec-project/bec_widgets/commit/2b9919bb34a66708f4b910ffc17dc253e9b7f70d))
### Features
- (#569) add signal label widget
([`822e7d0`](https://github.com/bec-project/bec_widgets/commit/822e7d06ff7479d006ae99942fed5e2c836831ce))
add a widget which shows the current value of a signal from BEC. configurable with many properties
in designer. intended for use mainly in static GUIs.
## v2.8.4 (2025-05-30)
### Bug Fixes
- **crosshair**: Label decimal precision is dynamically scaled with the plot zoom; API of all
affected widgets adjusted; option added to PlotBase; closes #637
([`c8128fa`](https://github.com/bec-project/bec_widgets/commit/c8128faf79c43487921aada9dbf1869ef5bda93c))
## v2.8.3 (2025-05-30)
### Bug Fixes
- Guard plugin repo import in e2e test
([`bf172b8`](https://github.com/bec-project/bec_widgets/commit/bf172b8431ec207f39206d2a0446908f7186858a))
### Refactoring
- Store modules with widget search
([`b225a7c`](https://github.com/bec-project/bec_widgets/commit/b225a7cc90b55697211c28d9411b6f85c8077217))
### Testing
- **e2e**: Add tests involving plugin repo
([`05329ab`](https://github.com/bec-project/bec_widgets/commit/05329ab50fe10ffc3c19ef3eb408912bb9068de3))
## v2.8.2 (2025-05-27)
### Bug Fixes

View File

@@ -242,7 +242,7 @@ class LaunchWindow(BECMainWindow):
)
# plugin widgets
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()

View File

@@ -52,6 +52,7 @@ _Widgets = {
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton",
"TextBox": "TextBox",
@@ -63,7 +64,7 @@ _Widgets = {
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
@@ -1221,6 +1222,20 @@ class Image(RPCBase):
Set auto range for the y-axis.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@minimal_crosshair_precision.setter
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -1237,16 +1252,16 @@ class Image(RPCBase):
@property
@rpc_call
def vrange(self) -> "tuple":
def v_range(self) -> "QPointF":
"""
Get the vrange of the image.
Set the v_range of the main image.
"""
@vrange.setter
@v_range.setter
@rpc_call
def vrange(self) -> "tuple":
def v_range(self) -> "QPointF":
"""
Get the vrange of the image.
Set the v_range of the main image.
"""
@property
@@ -2350,6 +2365,20 @@ class MultiWaveform(RPCBase):
The font size of the legend font.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@minimal_crosshair_precision.setter
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@property
@rpc_call
def highlighted_index(self):
@@ -3315,6 +3344,20 @@ class ScatterWaveform(RPCBase):
The font size of the legend font.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@minimal_crosshair_precision.setter
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@property
@rpc_call
def main_curve(self) -> "ScatterCurve":
@@ -3417,6 +3460,78 @@ class SignalComboBox(RPCBase):
"""
class SignalLabel(RPCBase):
@property
@rpc_call
def custom_label(self) -> "str":
"""
Use a cusom label rather than the signal name
"""
@property
@rpc_call
def custom_units(self) -> "str":
"""
Use a custom unit string
"""
@custom_label.setter
@rpc_call
def custom_label(self) -> "str":
"""
Use a cusom label rather than the signal name
"""
@custom_units.setter
@rpc_call
def custom_units(self) -> "str":
"""
Use a custom unit string
"""
@property
@rpc_call
def decimal_places(self) -> "int":
"""
Format to a given number of decimal_places. Set to 0 to disable.
"""
@decimal_places.setter
@rpc_call
def decimal_places(self) -> "int":
"""
Format to a given number of decimal_places. Set to 0 to disable.
"""
@property
@rpc_call
def show_default_units(self) -> "bool":
"""
Show default units obtained from the signal alongside it
"""
@show_default_units.setter
@rpc_call
def show_default_units(self) -> "bool":
"""
Show default units obtained from the signal alongside it
"""
@property
@rpc_call
def show_select_button(self) -> "bool":
"""
Show the button to select the signal to display
"""
@show_select_button.setter
@rpc_call
def show_select_button(self) -> "bool":
"""
Show the button to select the signal to display
"""
class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@@ -3789,6 +3904,20 @@ class Waveform(RPCBase):
The font size of the legend font.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@minimal_crosshair_precision.setter
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":
@@ -3841,6 +3970,48 @@ class Waveform(RPCBase):
The color palette of the figure widget.
"""
@property
@rpc_call
def skip_large_dataset_warning(self) -> "bool":
"""
Whether to skip the large dataset warning when fetching async data.
"""
@skip_large_dataset_warning.setter
@rpc_call
def skip_large_dataset_warning(self) -> "bool":
"""
Whether to skip the large dataset warning when fetching async data.
"""
@property
@rpc_call
def skip_large_dataset_check(self) -> "bool":
"""
Whether to skip the large dataset warning when fetching async data.
"""
@skip_large_dataset_check.setter
@rpc_call
def skip_large_dataset_check(self) -> "bool":
"""
Whether to skip the large dataset warning when fetching async data.
"""
@property
@rpc_call
def max_dataset_size_mb(self) -> "float":
"""
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
"""
@max_dataset_size_mb.setter
@rpc_call
def max_dataset_size_mb(self) -> "float":
"""
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
"""
@rpc_call
def plot(
self,

View File

@@ -111,7 +111,7 @@ _Widgets = {
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

@@ -31,10 +31,9 @@ class RPCWidgetHandler:
Returns:
None
"""
clss = get_custom_classes("bec_widgets")
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""

View File

@@ -184,8 +184,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
class Device(FakeDevice):

View File

@@ -163,7 +163,7 @@ class BECDispatcher:
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
cb_info: dict | None = None,
**kwargs,
) -> None:
@@ -172,7 +172,7 @@ class BECDispatcher:
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
@@ -183,13 +183,15 @@ class BECDispatcher:
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,

View File

@@ -3,12 +3,17 @@ from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
import traceback
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.bec_widget import BECWidget
from bec_lib.logger import bec_logger
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
logger = bec_logger.logger
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
@@ -30,7 +35,12 @@ def _loaded_submodules_from_specs(
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
submodule.__loader__.exec_module(submodule)
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
yield submodule
@@ -41,27 +51,29 @@ def _submodule_by_name(module: ModuleType, name: str):
return None
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
"""Find any BECWidget subclasses in the given module and return them with their info."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
classes = inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
for k, v in classes
)
def _all_widgets_from_all_submods(module):
def _all_widgets_from_all_submods(module) -> BECClassContainer:
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets.update(_all_widgets_from_all_submods(submod))
widgets += _all_widgets_from_all_submods(submod)
return widgets
@@ -75,15 +87,16 @@ def get_plugin_client_module() -> ModuleType | None:
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
def get_all_plugin_widgets() -> BECClassContainer:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return {}
return BECClassContainer()
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
print(get_all_plugin_widgets())
...

View File

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

View File

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

View File

@@ -34,13 +34,21 @@ class Crosshair(QObject):
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
def __init__(
self,
plot_item: pg.PlotItem,
precision: int | None = None,
*,
min_precision: int = 2,
parent=None,
):
"""
Crosshair for 1D and 2D plots.
Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
parent (QObject, optional): Parent object for the QObject. Defaults to None.
"""
super().__init__(parent)
@@ -48,7 +56,9 @@ class Crosshair(QObject):
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self._precision = precision
self._min_precision = max(0, int(min_precision)) # ensure nonnegative
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
@@ -93,6 +103,56 @@ class Crosshair(QObject):
self._connect_to_theme_change()
@property
def precision(self) -> int | None:
"""Fixed number of decimals; ``None`` enables dynamic mode."""
return self._precision
@precision.setter
def precision(self, value: int | None):
"""
Set the fixed number of decimals to display.
Args:
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
"""
self._precision = value
@property
def min_precision(self) -> int:
"""Lower bound on decimals when dynamic precision is used."""
return self._min_precision
@min_precision.setter
def min_precision(self, value: int):
"""
Set the lower bound on decimals when dynamic precision is used.
Args:
value(int): The minimum number of decimals to display. Must be non-negative.
"""
self._min_precision = max(0, int(value))
def _current_precision(self) -> int:
"""
Get the current precision based on the view range or fixed precision.
"""
if self._precision is not None:
return self._precision
# Dynamically choose precision from the smaller visible span
view_range = self.plot_item.vb.viewRange()
x_span = abs(view_range[0][1] - view_range[0][0])
y_span = abs(view_range[1][1] - view_range[1][0])
# Ignore zero spans that can appear during initialisation
spans = [s for s in (x_span, y_span) if s > 0]
span = min(spans) if spans else 1.0
exponent = np.floor(np.log10(span)) # order of magnitude
decimals = max(0, int(-exponent) + 1)
return max(self._min_precision, decimals)
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -324,6 +384,7 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
@@ -334,8 +395,8 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
@@ -380,6 +441,7 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
@@ -391,8 +453,8 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
@@ -443,7 +505,8 @@ class Crosshair(QObject):
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
precision = self._current_precision()
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
@@ -452,7 +515,7 @@ class Crosshair(QObject):
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{self.precision}g}"
text += f"\nIntensity: {intensity:.{precision}f}"
break
# Update coordinate label
self.coord_label.setText(text)

View File

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

View File

@@ -2,70 +2,99 @@ from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import NamedTuple
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
if items is not None and form_item_specs is not None:
logger.error(
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
)
items = None
if items is None and form_item_specs is None:
logger.error("Must specify one and only one of items and form_item_specs!")
items = []
super().__init__(parent=parent, client=client, **kwargs)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
for name, item_type in items # type: ignore
]
)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self):
self._clear_grid()
@@ -80,17 +109,20 @@ class TypedForm(BECWidget, QWidget):
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
def enumerate_form_widgets(self):
"""Return a generator over the rows of the form, with the row number, the label widget (to
which the field name is attached as a property), and the entry widget"""
grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount()):
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
row.label.property("_model_field_name"): row.widget.getValue()
for row in self.enumerate_form_widgets()
}
def _clear_grid(self):
@@ -103,10 +135,13 @@ class TypedForm(BECWidget, QWidget):
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
@@ -114,23 +149,52 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
"""
self._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._pretty_display = pretty_display
self._md_schema = data_model
super().__init__(
parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client
)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
@@ -147,9 +211,24 @@ class PydanticModelForm(TypedForm):
self._md_schema = schema
self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
FormItemSpec(
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
for name, info in self._md_schema.model_fields.items()
]

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from types import UnionType
from typing import Callable, Protocol
from types import GenericAlias, UnionType
from typing import Literal
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
@@ -21,11 +21,13 @@ from qtpy.QtWidgets import (
QLayout,
QLineEdit,
QRadioButton,
QSizePolicy,
QSpinBox,
QToolButton,
QWidget,
)
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
@@ -46,9 +48,36 @@ class FormItemSpec(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
item_type: type | UnionType | GenericAlias
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget):
@@ -94,10 +123,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent)
self._spec = spec
self._layout = QHBoxLayout()
@@ -107,11 +146,16 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(spec.info):
self._add_clear_button()
self._main_widget: QWidget
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod
def getValue(self): ...
def getValue(self) -> DynamicFormItemType: ...
@abstractmethod
def setValue(self, value): ...
@@ -121,6 +165,9 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
def _set_pretty_display(self):
self.setEnabled(False)
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
@@ -164,7 +211,7 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
self._main_widget.setText(value)
self._main_widget.setText(str(value))
class IntMetadataField(DynamicFormItem):
@@ -202,12 +249,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
min_, max_ = field_limits(self._spec.info, float, precision)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -224,10 +271,10 @@ class FloatDecimalMetadataField(DynamicFormItem):
return self._default
return self._main_widget.value()
def setValue(self, value: float):
def setValue(self, value: float | Decimal):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
self._main_widget.setValue(float(value))
class BoolMetadataField(DynamicFormItem):
@@ -251,6 +298,27 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value)
class DictMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
@@ -260,6 +328,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
if annotation in [dict, dict | None] or (
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
):
return DictMetadataField
if annotation in [list, list | None] or (
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
):
return StrMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -90,15 +90,15 @@ class BECClassInfo:
name: str
module: str
file: str
obj: type
obj: type[BECWidget]
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self):
self._collection: list[BECClassInfo] = []
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __repr__(self):
return str(list(cl.name for cl in self.collection))
@@ -106,6 +106,16 @@ class BECClassContainer:
def __iter__(self):
return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
@@ -115,53 +125,44 @@ class BECClassContainer:
"""
self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property
def collection(self):
"""
Get the collection of classes.
"""
"""Get the collection of classes."""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
"""Get all connector classes."""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
"""Get all top-level classes."""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
"""Get all plugins. These are all classes that are on the top level and are widgets."""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
"""Get all widgets. These are all classes inheriting from BECWidget."""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
"""Get all classes."""
return [info.obj for info in self.collection]
@@ -197,7 +198,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):

View File

@@ -195,7 +195,7 @@ class RPCServer:
return
self._broadcasted_data = data
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},

View File

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

View File

@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
from qtpy.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
@@ -31,12 +31,9 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
plugin_widgets = get_all_plugin_widgets()
self.custom_widgets.update(plugin_widgets)
self.custom_widgets = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
if PYSIDE6:
self.loader = self.load_ui_pyside6

View File

@@ -1,10 +1,11 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import Signal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Slot
from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -49,7 +50,7 @@ class DeviceSignalInputBase(BECWidget):
self._device = None
self.get_bec_shortcuts()
self._signal_filter = []
self._signal_filter = set()
self._signals = []
self._hinted_signals = []
self._normal_signals = []
@@ -60,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ###
@Slot(str)
@SafeSlot(str)
def set_signal(self, signal: str):
"""
Set the signal.
@@ -76,7 +77,7 @@ class DeviceSignalInputBase(BECWidget):
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
)
@Slot(str)
@SafeSlot(str)
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happens
@@ -90,8 +91,8 @@ class DeviceSignalInputBase(BECWidget):
self._device = device
self.update_signals_from_filters()
@Slot(dict, dict)
@Slot()
@SafeSlot(dict, dict)
@SafeSlot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
@@ -158,9 +159,9 @@ class DeviceSignalInputBase(BECWidget):
@include_hinted_signals.setter
def include_hinted_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.hinted)
self._signal_filter.add(Kind.hinted)
else:
self._signal_filter.remove(Kind.hinted)
self._signal_filter.discard(Kind.hinted)
self.update_signals_from_filters()
@Property(bool)
@@ -171,9 +172,9 @@ class DeviceSignalInputBase(BECWidget):
@include_normal_signals.setter
def include_normal_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.normal)
self._signal_filter.add(Kind.normal)
else:
self._signal_filter.remove(Kind.normal)
self._signal_filter.discard(Kind.normal)
self.update_signals_from_filters()
@Property(bool)
@@ -184,9 +185,9 @@ class DeviceSignalInputBase(BECWidget):
@include_config_signals.setter
def include_config_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.config)
self._signal_filter.add(Kind.config)
else:
self._signal_filter.remove(Kind.config)
self._signal_filter.discard(Kind.config)
self.update_signals_from_filters()
### Properties and Methods ###

View File

@@ -1,11 +1,13 @@
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
)
@@ -35,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self,
parent=None,
client=None,
config: DeviceSignalInputBase = None,
config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
@@ -65,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None:
self.set_signal(default)
def update_signals_from_filters(self):
@SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox"""
super().update_signals_from_filters()
super().update_signals_from_filters(content, metadata)
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@@ -84,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@Slot(str)
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.

View File

@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout
self._init_UI()
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@@ -45,7 +46,11 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
if role in [
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.ToolTipRole,
]:
return str(self._data[index.row()][index.column()])
def setData(self, index, value, role):
@@ -57,6 +62,11 @@ class DictBackedTableModel(QAbstractTableModel):
return True
return False
def replaceData(self, data: dict):
self.resetInternalData()
self._data = [[k, v] for k, v in data.items()]
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0))
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
@@ -110,16 +120,16 @@ class DictBackedTableModel(QAbstractTableModel):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_updated = Signal()
data_changed = Signal(dict)
def __init__(self, initial_data: list[list[str]]):
def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
super().__init__(parent)
self._layout = QHBoxLayout()
self.setLayout(self._layout)
@@ -127,13 +137,17 @@ class DictBackedTable(QWidget):
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
self._buttons = QVBoxLayout()
self._layout.addLayout(self._buttons)
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
@@ -143,11 +157,17 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@SafeSlot()
def clear(self):
self._table_model.replaceData({})
def replace_data(self, data: dict):
self._table_model.replaceData(data)
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
@@ -174,6 +194,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import sys
from decimal import Decimal
from math import inf, nextafter
from math import copysign, inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
def _nextafter(x, y):
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
_min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
_min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
_max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore

View File

@@ -16,6 +16,9 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
RPC = False
def __init__(
self,
parent=None,
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._additional_md_box = ExpandableGroupFrame(
parent, "Additional metadata", expanded=False
)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(initial_extras or [])
self._additional_metadata = DictBackedTable(parent, initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_updated.connect(self.validate_form)
self._additional_metadata.data_changed.connect(self.validate_form)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,6 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
@@ -43,6 +40,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
class ImageItem(BECConnector, pg.ImageItem):
RPC = True
USER_ACCESS = [
"color_map",
@@ -69,12 +67,13 @@ class ImageItem(BECConnector, pg.ImageItem):
]
vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image=None,
parent_image=None, # FIXME: rename to parent
**kwargs,
):
if config is None:
@@ -274,6 +273,8 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = []
self.max_len = 0
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
def remove(self, emit: bool = True):
self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())

View File

@@ -91,6 +91,8 @@ class MultiWaveform(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",

View File

@@ -116,6 +116,7 @@ class PlotBase(BECWidget, QWidget):
self._user_y_label = ""
self._y_label_suffix = ""
self._y_axis_units = ""
self._minimal_crosshair_precision = 3
# Plot Indicator Items
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
@@ -978,7 +979,9 @@ class PlotBase(BECWidget, QWidget):
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair = Crosshair(
self.plot_item, min_precision=self._minimal_crosshair_precision
)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
@@ -1006,6 +1009,29 @@ class PlotBase(BECWidget, QWidget):
self.unhook_crosshair()
@SafeProperty(
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
)
def minimal_crosshair_precision(self) -> int:
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
return self._minimal_crosshair_precision
@minimal_crosshair_precision.setter
def minimal_crosshair_precision(self, value: int):
"""
Set the minimum decimal places for crosshair when dynamic precision is enabled.
Args:
value(int): The minimum decimal places to set.
"""
value_int = max(0, int(value))
self._minimal_crosshair_precision = value_int
if self.crosshair is not None:
self.crosshair.min_precision = value_int
self.property_changed.emit("minimal_crosshair_precision", value_int)
@SafeSlot()
def reset(self) -> None:
"""Reset the plot widget."""

View File

@@ -82,6 +82,8 @@ class ScatterWaveform(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",

View File

@@ -60,6 +60,7 @@ class AxisSettings(SettingWidget):
self.ui.y_grid,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.minimal_crosshair_precision,
]:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@@ -121,6 +122,7 @@ class AxisSettings(SettingWidget):
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.minimal_crosshair_precision,
]:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
@@ -144,6 +146,7 @@ class AxisSettings(SettingWidget):
self.ui.y_grid,
self.ui.outer_axes,
self.ui.inner_axes,
self.ui.minimal_crosshair_precision,
]:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)

View File

@@ -14,97 +14,6 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="2" column="2" colspan="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
@@ -179,6 +88,87 @@
</layout>
</widget>
</item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="0" column="0" colspan="4">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
@@ -191,8 +181,41 @@
<item>
<widget class="QLineEdit" name="title"/>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout>
</widget>
<customwidgets>

View File

@@ -6,15 +6,84 @@
<rect>
<x>0</x>
<y>0</y>
<width>241</width>
<height>526</height>
<width>250</width>
<height>612</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="4" column="0" colspan="2">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="general_box">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="4" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="title"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
@@ -81,28 +150,7 @@
</layout>
</widget>
</item>
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="title"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<item>
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
@@ -169,23 +217,6 @@
</layout>
</widget>
</item>
<item row="2" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout>
</widget>
<customwidgets>

View File

@@ -22,6 +22,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
@@ -154,7 +155,7 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.color_button.color_changed.connect(self._on_color_changed)
self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 4
@@ -177,20 +178,16 @@ class CurveRow(QTreeWidgetItem):
self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin)
def _select_color(self, button):
@SafeSlot(str, verify_sender=True)
def _on_color_changed(self, new_color: str):
"""
Selects a new color using a color dialog and applies it to the specified button. Updates
related configuration properties based on the chosen color.
Update configuration when the color button emits a change.
Args:
button: The button widget whose color is being modified.
new_color (str): The new color in hex format.
"""
current_color = QColor(button.color())
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
self.config.color = new_color
self.config.symbol_color = new_color
def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'."""

View File

@@ -9,8 +9,19 @@ import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QMainWindow,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
@@ -33,6 +44,11 @@ class WaveformConfig(ConnectionConfig):
color_palette: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True
)
max_dataset_size_mb: float = Field(
10,
description="Maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.",
validate_default=True,
)
model_config: dict = {"validate_assignment": True}
_validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
@@ -86,6 +102,8 @@ class Waveform(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# Waveform Specific RPC Access
"curves",
"x_mode",
@@ -94,6 +112,12 @@ class Waveform(PlotBase):
"x_entry.setter",
"color_palette",
"color_palette.setter",
"skip_large_dataset_warning",
"skip_large_dataset_warning.setter",
"skip_large_dataset_check",
"skip_large_dataset_check.setter",
"max_dataset_size_mb",
"max_dataset_size_mb.setter",
"plot",
"add_dap_curve",
"remove_curve",
@@ -142,6 +166,7 @@ class Waveform(PlotBase):
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
# Scan data
self._scan_done = True # means scan is not running
self.old_scan_id = None
self.scan_id = None
self.scan_item = None
@@ -161,6 +186,10 @@ class Waveform(PlotBase):
self._init_curve_dialog()
self.curve_settings_dialog = None
# Largedataset guard
self._skip_large_dataset_warning = False # session flag
self._skip_large_dataset_check = False # per-plot flag, to skip the warning for this plot
# Scan status update loop
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
@@ -559,6 +588,59 @@ class Waveform(PlotBase):
"""
return [item for item in self.plot_item.curves if isinstance(item, Curve)]
@SafeProperty(bool)
def skip_large_dataset_check(self) -> bool:
"""
Whether to skip the large dataset warning when fetching async data.
"""
return self._skip_large_dataset_check
@skip_large_dataset_check.setter
def skip_large_dataset_check(self, value: bool):
"""
Set whether to skip the large dataset warning when fetching async data.
Args:
value(bool): Whether to skip the large dataset warning.
"""
self._skip_large_dataset_check = value
@SafeProperty(bool)
def skip_large_dataset_warning(self) -> bool:
"""
Whether to skip the large dataset warning when fetching async data.
"""
return self._skip_large_dataset_warning
@skip_large_dataset_warning.setter
def skip_large_dataset_warning(self, value: bool):
"""
Set whether to skip the large dataset warning when fetching async data.
Args:
value(bool): Whether to skip the large dataset warning.
"""
self._skip_large_dataset_warning = value
@SafeProperty(float)
def max_dataset_size_mb(self) -> float:
"""
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
"""
return self.config.max_dataset_size_mb
@max_dataset_size_mb.setter
def max_dataset_size_mb(self, value: float):
"""
Set the maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
Args:
value(float): The maximum dataset size in MB.
"""
if value <= 0:
raise ValueError("Maximum dataset size must be greater than 0.")
self.config.max_dataset_size_mb = value
################################################################################
# High Level methods for API
################################################################################
@@ -805,8 +887,6 @@ class Waveform(PlotBase):
if config.source == "device":
if self.scan_item is None:
self.update_with_scan_history(-1)
if curve in self._async_curves:
self._setup_async_curve(curve)
self.async_signal_update.emit()
self.sync_signal_update.emit()
if config.source == "dap":
@@ -1054,8 +1134,8 @@ class Waveform(PlotBase):
meta(dict): The message metadata.
"""
self.sync_signal_update.emit()
status = msg.get("done")
if status:
self._scan_done = msg.get("done")
if self._scan_done:
QTimer.singleShot(100, self.update_sync_curves)
QTimer.singleShot(300, self.update_sync_curves)
@@ -1133,9 +1213,11 @@ class Waveform(PlotBase):
if access_key == "val": # live access
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else: # history access
device_data = (
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
)
dataset_obj = data.get(device_name, {})
if self._skip_large_dataset_check is False:
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
continue # user declined to load; skip this curve
device_data = dataset_obj.get(device_entry, {}).read().get("value", None)
# if shape is 2D cast it into 1D and take the last waveform
if len(np.shape(device_data)) > 1:
@@ -1579,6 +1661,8 @@ class Waveform(PlotBase):
dev_name = curve.config.signal.name
if dev_name in readout_priority_async:
self._async_curves.append(curve)
if hasattr(self.scan_item, "live_data"):
self._setup_async_curve(curve)
found_async = True
elif dev_name in readout_priority_sync:
self._sync_curves.append(curve)
@@ -1655,6 +1739,106 @@ class Waveform(PlotBase):
################################################################################
# Utility Methods
################################################################################
# Large dataset handling helpers
def _check_dataset_size_and_confirm(self, dataset_obj, device_entry: str) -> bool:
"""
Check the size of the dataset and confirm with the user if it exceeds the limit.
Args:
dataset_obj: The dataset object containing the information.
device_entry( str): The specific device entry to check.
Returns:
bool: True if the dataset is within the size limit or user confirmed to load it,
False if the dataset exceeds the size limit and user declined to load it.
"""
try:
info = dataset_obj._info
mem_bytes = info.get(device_entry, {}).get("value", {}).get("mem_size", 0)
# Fallback grab first entry if lookup failed
if mem_bytes == 0 and info:
first_key = next(iter(info))
mem_bytes = info[first_key]["value"]["mem_size"]
size_mb = mem_bytes / (1024 * 1024)
print(f"Dataset size: {size_mb:.1f} MB")
except Exception as exc: # noqa: BLE001
logger.error(f"Unable to evaluate dataset size: {exc}")
return True
if size_mb <= self.config.max_dataset_size_mb:
return True
logger.warning(
f"Attempt to load large dataset: {size_mb:.1f} MB "
f"(limit {self.config.max_dataset_size_mb} MB)"
)
if self._skip_large_dataset_warning:
logger.info("Skipping large dataset warning dialog.")
return False
return self._confirm_large_dataset(size_mb)
def _confirm_large_dataset(self, size_mb: float) -> bool:
"""
Confirm with the user whether to load a large dataset with dialog popup.
Also allows the user to adjust the maximum dataset size limit and if user
wants to see this popup again during session.
Args:
size_mb(float): Size of the dataset in MB.
Returns:
bool: True if the user confirmed to load the dataset, False otherwise.
"""
if self._skip_large_dataset_warning:
return True
dialog = QDialog(self)
dialog.setWindowTitle("Large dataset detected")
main_dialog_layout = QVBoxLayout(dialog)
# Limit adjustment widgets
limit_adjustment_layout = QHBoxLayout()
limit_adjustment_layout.addWidget(QLabel("New limit (MB):"))
spin = QDoubleSpinBox()
spin.setRange(0.001, 4096)
spin.setDecimals(3)
spin.setSingleStep(0.01)
spin.setValue(self.config.max_dataset_size_mb)
spin.valueChanged.connect(lambda value: setattr(self.config, "max_dataset_size_mb", value))
limit_adjustment_layout.addWidget(spin)
# Don't show again checkbox
checkbox = QCheckBox("Don't show this again for this session")
buttons = QDialogButtonBox(
QDialogButtonBox.Yes | QDialogButtonBox.No, Qt.Horizontal, dialog
)
buttons.accepted.connect(dialog.accept) # Yes
buttons.rejected.connect(dialog.reject) # No
# widget layout
main_dialog_layout.addWidget(
QLabel(
f"The selected dataset is {size_mb:.1f} MB which exceeds the "
f"current limit of {self.config.max_dataset_size_mb} MB.\n"
)
)
main_dialog_layout.addLayout(limit_adjustment_layout)
main_dialog_layout.addWidget(checkbox)
main_dialog_layout.addWidget(QLabel("Would you like to display dataset anyway?"))
main_dialog_layout.addWidget(buttons)
result = dialog.exec() # modal; waits for user choice
# Respect the “don't show again” checkbox for *either* choice
if checkbox.isChecked():
self._skip_large_dataset_warning = True
if result == QDialog.Accepted:
self.config.max_dataset_size_mb = spin.value()
return True
return False
def _ensure_str_list(self, entries: list | tuple | np.ndarray):
"""
Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
@@ -1785,7 +1969,7 @@ class DemoApp(QMainWindow): # pragma: no cover
self.setCentralWidget(self.main_widget)
self.waveform_popup = Waveform(popups=True)
self.waveform_popup.plot(y_name="monitor_async")
self.waveform_popup.plot(y_name="waveform")
self.waveform_side = Waveform(popups=False)
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")

View File

@@ -1,15 +1,22 @@
import os
import re
from typing import Optional
from functools import partial
from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
class DeviceBrowser(BECWidget, QWidget):
@@ -23,18 +30,18 @@ class DeviceBrowser(BECWidget, QWidget):
def __init__(
self,
parent: Optional[QWidget] = None,
parent: QWidget | None = None,
config=None,
client=None,
gui_id: Optional[str] = None,
gui_id: str | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.ui = None
self.ini_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
)
@@ -43,6 +50,7 @@ class DeviceBrowser(BECWidget, QWidget):
)
self.device_update.connect(self.update_device_list)
self.init_device_list()
self.update_device_list()
def ini_ui(self) -> None:
@@ -50,14 +58,12 @@ class DeviceBrowser(BECWidget, QWidget):
Initialize the UI by loading the UI file and setting the layout.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
self.ui = UILoader(self).loader(ui_file_path)
layout.addWidget(self.ui)
self.setLayout(layout)
def on_device_update(self, action: str, content: dict) -> None:
def on_device_update(self, action: ConfigAction, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -68,8 +74,43 @@ class DeviceBrowser(BECWidget, QWidget):
if action in ["add", "remove", "reload"]:
self.device_update.emit()
@Slot()
def update_device_list(self) -> None:
def init_device_list(self):
self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
device_item.adjustSize()
item.setSizeHint(QSize(device_item.width(), device_item.height()))
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
with RPCRegister.delayed_broadcast():
for device, device_obj in self.dev.items():
item = QListWidgetItem(self.dev_list)
device_item = DeviceItem(
parent=self, device=device, icon=map_device_type_to_icon(device_obj)
)
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
device_config = self.dev[device]._config # pylint: disable=protected-access
device_item.set_display_config(device_config)
tooltip = device_config.get("description", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot()
def reset_device_list(self) -> None:
self.init_device_list()
self.update_device_list()
@SafeSlot()
@SafeSlot(str)
def update_device_list(self, *_) -> None:
"""
Update the device list based on the filter input.
There are two ways to trigger this function:
@@ -80,23 +121,14 @@ class DeviceBrowser(BECWidget, QWidget):
"""
filter_text = self.ui.filter_input.text()
try:
regex = re.compile(filter_text, re.IGNORECASE)
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
regex = None # Invalid regex, disable filtering
dev_list = self.ui.device_list
dev_list.clear()
self.regex = None # Invalid regex, disable filtering
for device in self.dev:
self._device_items[device].setHidden(False)
return
for device in self.dev:
if regex is None or regex.search(device):
item = QListWidgetItem(dev_list)
device_item = DeviceItem(device)
# pylint: disable=protected-access
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
item.setSizeHint(device_item.sizeHint())
dev_list.setItemWidget(item, device_item)
dev_list.addItem(item)
self._device_items[device].setHidden(not self.regex.search(device))
if __name__ == "__main__": # pragma: no cover
@@ -104,10 +136,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
apply_theme("light")
set_theme("light")
widget = DeviceBrowser()
widget.show()
sys.exit(app.exec_())

View File

@@ -2,10 +2,18 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.logger import bec_logger
from qtpy.QtCore import QMimeData, Qt
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_name
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent
@@ -13,26 +21,77 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
class DeviceItem(QWidget):
def __init__(self, device: str) -> None:
super().__init__()
class DeviceItemForm(PydanticModelForm):
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
super().__init__(
parent=parent,
data_model=DeviceConfigModel,
pretty_display=pretty_display,
client=client,
**kwargs,
)
self._validity.setVisible(False)
self._connect_to_theme_change()
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
theme = get_theme_name()
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
class DeviceItem(ExpandableGroupFrame):
broadcast_size_hint = Signal(QSize)
RPC = False
def __init__(self, parent, device: str, icon: str = "") -> None:
super().__init__(parent, title=device, expanded=False, icon=icon)
self._drag_pos = None
self._expanded_first_time = False
self._data = None
self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(10, 2, 10, 2)
self.label = QLabel(device)
layout.addWidget(self.label)
self.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
self.set_layout(layout)
self.setStyleSheet(
"""
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
"""
)
self.adjustSize()
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
@SafeSlot()
def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time:
self._expanded_first_time = True
self.form = DeviceItemForm(parent=self, pretty_display=True)
self._contents.layout().addWidget(self.form)
if self._data:
self.form.set_data(self._data)
self.broadcast_size_hint.emit(self.sizeHint())
super().switch_expanded_state()
if self._expanded_first_time:
self.form.adjustSize()
self.updateGeometry()
if self._expanded:
self.form.set_pretty_display_theme()
self.adjustSize()
self.broadcast_size_hint.emit(self.sizeHint())
def set_display_config(self, config_dict: dict):
"""Set the displayed information from a device config dict, which must conform to the
bec_lib.atlas_models.Device config model."""
self._data = DeviceConfigModel.model_validate(config_dict)
if self._expanded_first_time:
self.form.set_data(self._data)
def mousePressEvent(self, event: QMouseEvent) -> None:
super().mousePressEvent(event)
@@ -63,6 +122,25 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = DeviceItem("Device")
widget = QWidget()
layout = QHBoxLayout()
widget.setLayout(layout)
item = DeviceItem("Device")
layout.addWidget(DarkModeButton())
layout.addWidget(item)
item.set_display_config(
{
"name": "Test Device",
"enabled": True,
"deviceClass": "FakeDeviceClass",
"deviceConfig": {"kwarg1": "value1"},
"readoutPriority": "baseline",
"description": "A device for testing out a widget",
"readOnly": True,
"softwareTrigger": False,
"deviceTags": ["tag1", "tag2", "tag3"],
"userParameter": {"some_setting": "some_ value"},
}
)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,11 @@
from bec_lib.device import Device
def map_device_type_to_icon(device_obj: Device) -> str:
"""Associate device types with material icon names"""
match device_obj._info.get("device_base_class", "").lower():
case "positioner":
return "precision_manufacturing"
case "signal":
return "vital_signs"
return "deployed_code"

View File

@@ -11,12 +11,11 @@ from re import Pattern
from typing import TYPE_CHECKING, Literal
from bec_lib.client import BECClient
from bec_lib.connector import ConnectorBase
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from PySide6.QtCore import QObject
from qtpy.QtCore import QDateTime, Qt, Signal
from pyqtgraph import SignalProxy
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
@@ -35,6 +34,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import get_theme_palette, set_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.text_box.text_box import TextBox
@@ -69,22 +69,22 @@ DEFAULT_LOG_COLORS = {
}
class BecLogsQueue(QObject):
class BecLogsQueue(BECConnector, QObject):
"""Manages getting logs from BEC Redis and formatting them for display"""
RPC = False
new_message = Signal()
def __init__(
self,
parent: QObject | None,
conn: ConnectorBase,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
**kwargs,
) -> None:
super().__init__(parent=parent)
super().__init__(parent=parent, **kwargs)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._conn = conn
self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
@@ -92,20 +92,26 @@ class BecLogsQueue(QObject):
self._search_query: Pattern | str | None = None
self._selected_services: set[str] | None = None
self._set_formatter_and_update_filter(line_formatter)
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
def unsub_from_redis(self):
def cleanup(self, *_):
"""Stop listening to the Redis log stream"""
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()]
)
def _process_incoming_log_msg(self, msg: dict):
@SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
try:
_msg: LogMessage = msg["data"]
_msg = LogMessage(**msg)
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
self.new_message.emit()
except Exception as e:
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
return
logger.warning(f"Error in LogPanel incoming message callback: {e}")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
@@ -202,7 +208,7 @@ class BecLogsQueue(QObject):
"""Fetch all available messages from Redis"""
self._data = deque(
item["data"]
for item in self._conn.xread(
for item in self.bec_dispatcher.client.connector.xread(
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
)
)
@@ -396,7 +402,6 @@ class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
_new_messages = Signal()
service_list_update = Signal(dict, set)
def __init__(
@@ -407,17 +412,17 @@ class LogPanel(TextBox):
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, **kwargs)
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
parent,
self.client.connector, # type: ignore
line_formatter=partial(simple_color_format, colors=self._colors),
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
)
self._proxy_update = SignalProxy(
self._log_manager.new_message, rateLimit=1, slot=self._on_append
)
self._log_manager.new_message.connect(self._new_messages)
self.toolbar = LogPanelToolbar(parent=parent)
self.toolbar = LogPanelToolbar(parent=self)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
@@ -431,7 +436,6 @@ class LogPanel(TextBox):
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self._new_messages.connect(self._on_append)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
@@ -483,10 +487,10 @@ class LogPanel(TextBox):
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_append(self):
self._cursor_to_end()
@SafeSlot(verify_sender=True)
def _on_append(self, *_):
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
self._cursor_to_end()
@SafeSlot()
def _on_clear(self):
@@ -529,9 +533,8 @@ class LogPanel(TextBox):
def cleanup(self):
self._service_status.cleanup()
self._log_manager.unsub_from_redis()
self._log_manager.new_message.disconnect(self._new_messages)
self._new_messages.disconnect(self._on_append)
self._log_manager.cleanup()
self._log_manager.deleteLater()
super().cleanup()

View File

@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
from bec_widgets.widgets.utility.signal_label.signal_label_plugin import SignalLabelPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLabelPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1,456 @@
from __future__ import annotations
import sys
import traceback
from typing import TYPE_CHECKING
from bec_lib.device import Device, Signal
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Signal as QSignal
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
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.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
DeviceInputConfig,
)
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
if TYPE_CHECKING:
from bec_lib.client import BECClient
class ChoiceDialog(QDialog):
accepted_output = QSignal(str, str)
CONNECTION_ERROR_STR = "Error: client is not connected!"
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client: BECClient | None = None,
show_hinted: bool = True,
show_normal: bool = False,
show_config: bool = False,
):
if not client or not client.started:
self._display_error()
return
super().__init__(parent=parent)
self.setWindowTitle("Choose device and signal...")
self._accent_colors = get_accent_colors()
layout = QHBoxLayout()
config_dict = config.model_dump() if config is not None else {}
self._device_config = DeviceInputConfig.model_validate(config_dict)
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
self._device_field = DeviceLineEdit(
config=self._device_config, parent=parent, client=client
)
self._signal_field = SignalComboBox(
config=self._signal_config,
device=self._signal_config.device,
parent=parent,
client=client,
)
layout.addWidget(self._device_field)
layout.addWidget(self._signal_field)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
self._signal_field.include_hinted_signals = show_hinted
self._signal_field.include_normal_signals = show_normal
self._signal_field.include_config_signals = show_config
self.setLayout(layout)
self._device_field.textChanged.connect(self._update_device)
self._device_field.setText(config.device if config is not None else "")
def _display_error(self):
try:
super().__init__()
except Exception:
...
layout = QHBoxLayout()
layout.addWidget(QLabel(self.CONNECTION_ERROR_STR))
self.button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.reject)
layout.addWidget(self.button_box)
self.setLayout(layout)
@SafeSlot(str)
def _update_device(self, device: str):
if device in self._device_field.dev:
self._device_field.set_device(device)
self._signal_field.set_device(device)
self._device_field.setStyleSheet(
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
else:
self._device_field.setStyleSheet(
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
self._signal_field.clear()
def accept(self):
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
return super().accept()
class SignalLabel(BECWidget, QWidget):
ICON_NAME = "scoreboard"
RPC = True
PLUGIN = True
USER_ACCESS = [
"custom_label",
"custom_units",
"custom_label.setter",
"custom_units.setter",
"decimal_places",
"decimal_places.setter",
"show_default_units",
"show_default_units.setter",
"show_select_button",
"show_select_button.setter",
]
def __init__(
self,
parent: QWidget | None = None,
client: BECClient | None = None,
device: str | None = None,
signal: str | None = None,
show_select_button: bool = True,
show_default_units: bool = False,
custom_label: str = "",
custom_units: str = "",
**kwargs,
):
"""Initialize the SignalLabel widget.
Args:
parent (QWidget, optional): The parent widget. Defaults to None.
client (BECClient, optional): The BEC client. Defaults to None.
device (str, optional): The device name. Defaults to None.
signal (str, optional): The signal name. Defaults to None.
selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog.
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
show_default_units (bool, optional): Whether to show default units. Defaults to False.
custom_label (str, optional): Custom label for the widget. Defaults to "".
custom_units (str, optional): Custom units for the widget. Defaults to "".
"""
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
super().__init__(parent=parent, client=client, **kwargs)
self._device = device
self._signal = signal
self._custom_label: str = custom_label
self._custom_units: str = custom_units
self._show_default_units: bool = show_default_units
self._decimal_places = 3
self._show_hinted_signals: bool = True
self._show_normal_signals: bool = False
self._show_config_signals: bool = False
self._outer_layout = QHBoxLayout()
self._layout = QHBoxLayout()
self._outer_layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._outer_layout)
self._label = QGroupBox(custom_label)
self._outer_layout.addWidget(self._label)
self._update_label()
self._label.setLayout(self._layout)
self._value: str = ""
self._display = QLabel()
self._layout.addWidget(self._display)
self._select_button = QToolButton()
self._select_button.setIcon(material_icon(icon_name="settings", size=(20, 20)))
self._show_select_button: bool = show_select_button
self._layout.addWidget(self._select_button)
self._display.setMinimumHeight(self._select_button.sizeHint().height())
self.show_select_button = self._show_select_button
self._select_button.clicked.connect(self.show_choice_dialog)
self.get_bec_shortcuts()
self._connected: bool = False
self.connect_device()
def _create_dialog(self):
return ChoiceDialog(
config=self._config,
parent=self,
client=self.client,
show_config=self.show_config_signals,
show_normal=self.show_normal_signals,
show_hinted=self.show_hinted_signals,
)
@SafeSlot()
def _process_dialog(self, device: str, signal: str):
self.disconnect_device()
self.device = device
self.signal = signal
self._update_label()
self.connect_device()
def show_choice_dialog(self):
dialog = self._create_dialog()
dialog.accepted_output.connect(self._process_dialog)
dialog.open()
return dialog
def connect_device(self):
"""Subscribe to the Redis topic for the device to display"""
if not self._connected and self._device and self._device in self.dev:
self._connected = True
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
self._manual_read()
self.set_display_value(self._value)
def disconnect_device(self):
"""Unsubscribe from the Redis topic for the device to display"""
if self._connected:
self._connected = False
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
def _manual_read(self):
if self._device is None or not isinstance(
(device := self.dev.get(self._device)), Device | Signal
):
self._units = ""
self._value = "__"
return
signal: Signal = (
getattr(device, self.signal, None) if isinstance(device, Device) else device
)
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
signal = None
if signal is None:
self._units = ""
self._value = "__"
return
self._value = signal.get()
self._units = signal.get_device_config().get("egu", "")
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
"""
Update the display with the new value.
"""
try:
signal_to_read = self._patch_hinted_signal()
self._value = msg["signals"][signal_to_read]["value"]
self.set_display_value(self._value)
except Exception as e:
self._display.setText("ERROR!")
self._display.setToolTip(
f"Error processing incoming reading: {msg}, handled with exception: {''.join(traceback.format_exception(e))}"
)
def _patch_hinted_signal(self):
if self.dev[self._device]._info["signals"] == {}:
return self._signal
signal_info = self.dev[self._device]._info["signals"][self._signal]
return (
signal_info["obj_name"] if signal_info["kind_str"] == Kind.hinted.name else self._signal
)
@SafeProperty(str)
def device(self) -> str:
"""The device from which to select a signal"""
return self._device or "Not set!"
@device.setter
def device(self, value: str) -> None:
self.disconnect_device()
self._device = value
self._config.device = value
self.connect_device()
self._update_label()
@SafeProperty(str)
def signal(self) -> str:
"""The signal to display"""
return self._signal or "Not set!"
@signal.setter
def signal(self, value: str) -> None:
self.disconnect_device()
self._signal = value
self._config.default = value
self.connect_device()
self._update_label()
@SafeProperty(bool)
def show_select_button(self) -> bool:
"""Show the button to select the signal to display"""
return self._show_select_button
@show_select_button.setter
def show_select_button(self, value: bool) -> None:
self._show_select_button = value
self._select_button.setVisible(value)
@SafeProperty(bool)
def show_default_units(self) -> bool:
"""Show default units obtained from the signal alongside it"""
return self._show_default_units
@show_default_units.setter
def show_default_units(self, value: bool) -> None:
self._show_default_units = value
self.set_display_value(self._value)
@SafeProperty(str)
def custom_label(self) -> str:
"""Use a cusom label rather than the signal name"""
return self._custom_label
@custom_label.setter
def custom_label(self, value: str) -> None:
self._custom_label = value
self._update_label()
@SafeProperty(str)
def custom_units(self) -> str:
"""Use a custom unit string"""
return self._custom_units
@custom_units.setter
def custom_units(self, value: str) -> None:
self._custom_units = value
self.set_display_value(self._value)
@SafeProperty(int)
def decimal_places(self) -> int:
"""Format to a given number of decimal_places. Set to 0 to disable."""
return self._decimal_places
@decimal_places.setter
def decimal_places(self, value: int) -> None:
self._decimal_places = value
self._update_label()
@SafeProperty(bool)
def show_hinted_signals(self) -> bool:
"""In the signal selection menu, show hinted signals"""
return self._show_hinted_signals
@show_hinted_signals.setter
def show_hinted_signals(self, value: bool) -> None:
self._show_hinted_signals = value
@SafeProperty(bool)
def show_config_signals(self) -> bool:
"""In the signal selection menu, show config signals"""
return self._show_config_signals
@show_config_signals.setter
def show_config_signals(self, value: bool) -> None:
self._show_config_signals = value
@SafeProperty(bool)
def show_normal_signals(self) -> bool:
"""In the signal selection menu, show normal signals"""
return self._show_normal_signals
@show_normal_signals.setter
def show_normal_signals(self, value: bool) -> None:
self._show_normal_signals = value
def _format_value(self, value: str):
if self._decimal_places == 0:
return value
try:
return f"{float(value):0.{self._decimal_places}f}"
except ValueError:
return value
@SafeSlot(str)
def set_display_value(self, value: str):
"""Set the display to a given value, appending the units if specified"""
self._display.setText(f"{self._format_value(value)}{self._units_string}")
self._display.setToolTip("")
@property
def _units_string(self):
if self.custom_units or self._show_default_units:
return f" {self.custom_units or self._default_units or ''}"
return ""
@property
def _default_units(self) -> str:
return self._units
@property
def _default_label(self) -> str:
return (
str(self._signal) if self._device == self._signal else f"{self._device} {self._signal}"
)
def _update_label(self):
self._label.setTitle(
self._custom_label if self._custom_label else f"{self._default_label}:"
)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = QWidget()
w.setLayout(QVBoxLayout())
w.layout().addWidget(
SignalLabel(
device="samx",
signal="readback",
custom_label="custom label:",
custom_units=" m/s/s",
show_select_button=False,
)
)
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
l = SignalLabel()
l.device = "bpm4i"
l.signal = "bpm4i"
w.layout().addWidget(l)
w.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,43 +1,39 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.console.console import BECConsole
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
DOM_XML = """
<ui language='c++'>
<widget class='BECConsole' name='bec_console'>
<widget class='SignalLabel' name='signal_label'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class SignalLabelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECConsole(parent)
t = SignalLabel(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Console"
return "BEC Utils"
def icon(self):
return designer_material_icon(BECConsole.ICON_NAME)
return designer_material_icon(SignalLabel.ICON_NAME)
def includeFile(self):
return "bec_console"
return "signal_label"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -49,10 +45,10 @@ class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "BECConsole"
return "SignalLabel"
def toolTip(self):
return "A terminal-like vt100 widget."
return "Display the live value of any signal"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QPushButton
from qtpy.QtWidgets import QColorDialog, QPushButton
from bec_widgets import BECWidget, SafeProperty, SafeSlot
@@ -12,6 +15,8 @@ class ColorButtonNative(BECWidget, QPushButton):
to guarantee good readability.
"""
color_changed = Signal(str)
RPC = False
PLUGIN = True
ICON_NAME = "colors"
@@ -25,9 +30,10 @@ class ColorButtonNative(BECWidget, QPushButton):
"""
super().__init__(parent=parent, **kwargs)
self.set_color(color)
self.clicked.connect(self._open_color_dialog)
@SafeSlot()
def set_color(self, color):
def set_color(self, color: str | QColor):
"""Set the button's color and update its appearance.
Args:
@@ -38,6 +44,7 @@ class ColorButtonNative(BECWidget, QPushButton):
else:
self._color = color
self._update_appearance()
self.color_changed.emit(self._color)
@SafeProperty("QColor")
def color(self):
@@ -56,3 +63,11 @@ class ColorButtonNative(BECWidget, QPushButton):
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)
@SafeSlot()
def _open_color_dialog(self):
"""Open a QColorDialog and apply the selected color."""
current_color = QColor(self._color)
chosen_color = QColorDialog.getColor(current_color, self, "Select Curve Color")
if chosen_color.isValid():
self.set_color(chosen_color)

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -0,0 +1,102 @@
(user.widgets.signal_label)=
# Signal Label widget
````{tab} Overview
The [`SignalLabel`](/api_reference/_autosummary/bec_widgets.cli.client.SignalLabel) displays the value of a signal from a device, with optional customization for labels, units, decimal formatting, and signal selection. It is designed for use in BEC (Beamline Experiment Control) GUIs to monitor values which beamline operators might want to keep an eye on, e.g. sample position, flux, hutch state...
## Key Features:
- Display: Shows the current value of a device signal.
- Custom Label/Units: Optionally override the default label and units.
- Decimal Formatting: Control the number of decimal places shown.
- Signal Selection: (Optional) Button to open a dialog for selecting a device and signal.
- Live Updates: Subscribes to device updates and refreshes the display automatically.
````
````{tab} Examples - python
The `SignalLabel` widget can be used inside another widget to build an overall GUI display. For example, to create a display
for the sample position like this:
```{figure} ./test_screenshot.png
```
You can simply add three of these signal displays as done here:
```python
import sys
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
class SamplePositionWidget(BECWidget, QWidget):
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setLayout(QVBoxLayout())
self.samx_readback = SignalLabel(
device="samx",
signal="readback",
custom_label="Sample X:",
custom_units="mm",
show_select_button=False,
show_default_units=False,
)
self.samy_readback = SignalLabel(
device="samy",
signal="readback",
custom_label="Sample Y:",
custom_units="mm",
show_select_button=False,
show_default_units=False,
)
self.samz_readback = SignalLabel(
device="samz",
signal="readback",
custom_label="Sample Z:",
custom_units="mm",
show_select_button=False,
show_default_units=False,
)
self.layout().addWidget(self.samx_readback)
self.layout().addWidget(self.samy_readback)
self.layout().addWidget(self.samz_readback)
if __name__ == "__main__":
app = QApplication()
w = SamplePositionWidget()
w.show()
sys.exit(app.exec_())
```
````
````{tab} Examples - BEC desginer
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
```{figure} ./designer_screenshot.png
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.TextBox.rst
```
````

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -175,6 +175,14 @@ Various buttons which manage the control of the BEC Queue.
Choose individual device from current session.
```
```{grid-item-card} Signal Label
:link: user.widgets.signal_label
:link-type: ref
:img-top: ./signal_label/test_screenshot.png
Display the live value of a signal.
```
```{grid-item-card} Signal Input Widgets
:link: user.widgets.signal_input
:link-type: ref
@@ -289,5 +297,7 @@ lmfit_dialog/lmfit_dialog.md
dap_combo_box/dap_combo_box.md
games/games.md
log_panel/log_panel.md
signal_label/signal_label.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.8.2"
version = "2.12.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -21,7 +21,6 @@ dependencies = [
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"pyte", # needed for vt100 console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
]

View File

@@ -5,11 +5,20 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.widgets.control.scan_control import ScanControl
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture(autouse=True)
def threads_check_fixture(threads_check):
"""

View File

@@ -145,7 +145,14 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Check that the number of top level widgets is still the same. As the cleanup is done by the
# qt event loop, we need to wait for the qtbot to finish the cleanup
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
try:
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
except Exception as exc:
raise RuntimeError(
f"Widget {object_name} was not removed properly. The number of top level widgets "
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
f"widgets are still registered: {list(gui._server_registry.keys())}."
) from exc
# Number of widgets with parent_id == None, should be 2
widgets = [
widget

View File

@@ -3,15 +3,6 @@ import time
import pytest
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_scan_control_populate_scans_e2e(scan_control):
@@ -27,6 +18,7 @@ def test_scan_control_populate_scans_e2e(scan_control):
"monitor_scan",
"acquire",
"line_scan",
"custom_testing_scan",
]
items = [
scan_control.comboBox_scan_selection.itemText(i)

View File

@@ -0,0 +1,94 @@
import time
import pytest
try:
from bec_testing_plugin.scans.metadata_schema.custom_test_scan_schema import CustomScanSchema
except ImportError:
pytest.skip(reason="Requires plugin repo!", allow_module_level=True)
from qtpy.QtWidgets import QGridLayout
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.mark.parametrize(
["md", "valid"],
[
({"treatment_description": "soaking", "treatment_temperature_k": 123}, True),
({"treatment_description": "soaking", "treatment_temperature_k": "wrong type"}, False),
({"treatment_description": "soaking", "wrong key": 123}, False),
(
{
"sample_name": "test sample",
"treatment_description": "soaking",
"treatment_temperature_k": 123,
},
True,
),
],
)
def test_scan_metadata_for_custom_scan(
scan_control: ScanControl, bec_client_lib, qtbot, md: dict, valid: bool
):
client = bec_client_lib
queue = client.queue
scan_name = "custom_testing_scan"
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
args = {"device": "samx", "start": -5, "stop": 5}
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
for key, value in kwargs.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Set args in the UI
for widget in scan_control.arg_box.widgets:
for key, value in args.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
assert scan_control._metadata_form._md_schema == CustomScanSchema
assert not scan_control.button_run_scan.isEnabled()
def do_test():
# Set the metadata
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
for i in range(grid.rowCount()): # type: ignore
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
if (value_to_set := md.pop(field_name, None)) is not None:
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
# all values should be used
assert md == {}
assert scan_control.button_run_scan.isEnabled()
# Run the scan
scan_control.button_run_scan.click()
time.sleep(2)
last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
if valid:
do_test()
else:
with pytest.raises(Exception):
do_test()

View File

@@ -2,7 +2,9 @@ from importlib.machinery import FileFinder, SourceFileLoader
from types import ModuleType
from unittest import mock
from bec_widgets.utils.bec_plugin_helper import BECWidget, _all_widgets_from_all_submods
from bec_widgets.utils.bec_plugin_helper import _all_widgets_from_all_submods
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
def test_all_widgets_from_module_no_submodules():
@@ -39,10 +41,17 @@ def test_all_widgets_from_module_with_submodules():
mock.patch("importlib.util.module_from_spec", return_value=submodule),
mock.patch(
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
side_effect=[{"TestWidget": BECWidget}, {"SubWidget": BECWidget}],
side_effect=[
BECClassContainer(
[BECClassInfo(name="TestWidget", module="", obj=BECWidget, file="")]
),
BECClassContainer(
[BECClassInfo(name="SubWidget", module="", obj=BECWidget, file="")]
),
],
),
):
widgets = _all_widgets_from_all_submods(module)
widgets = _all_widgets_from_all_submods(module).as_dict()
assert widgets == {"TestWidget": BECWidget, "SubWidget": BECWidget}
@@ -54,8 +63,9 @@ def test_all_widgets_from_module_no_widgets():
module = mock.MagicMock()
with mock.patch(
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module", return_value={}
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
return_value=BECClassContainer([]),
):
widgets = _all_widgets_from_all_submods(module)
widgets = _all_widgets_from_all_submods(module).as_dict()
assert widgets == {}

View File

@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, call, patch
from bec_widgets.cli import client
from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
class _TestGlobalPlugin(RPCBase): ...
@@ -47,7 +48,9 @@ mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
)
@patch(
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
return_value={"DeviceComboBox": _TestDuplicatePlugin},
return_value=BECClassContainer(
[BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")]
),
)
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client)

View File

@@ -0,0 +1,87 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QColorDialog
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from .conftest import create_widget
def test_color_button_native(qtbot):
cb = create_widget(qtbot, ColorButtonNative)
# Check if the instance is created successfully
assert cb is not None
# Check if the button has a default color
assert cb.color is not None
# Check if the button can change color
new_color = QColor(255, 0, 0) # Red
cb.set_color(new_color)
assert cb.color == new_color.name()
def test_color_dialog_applies_chosen_color(qtbot, monkeypatch):
"""Clicking the button should open the dialog and apply the selected color."""
cb = create_widget(qtbot, ColorButtonNative)
chosen_color = QColor(0, 255, 0) # Green
# Force QColorDialog.getColor to return our chosen color
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: chosen_color)
# Expect the color_changed signal during the click
with qtbot.waitSignal(cb.color_changed, timeout=1000):
qtbot.mouseClick(cb, Qt.LeftButton)
assert cb.color == chosen_color.name()
def test_color_dialog_cancel_keeps_color(qtbot, monkeypatch):
"""If the dialog returns an invalid color, the button color should stay the same."""
cb = create_widget(qtbot, ColorButtonNative)
original_color = cb.color
# Simulate cancel: return an invalid QColor
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: QColor())
qtbot.mouseClick(cb, Qt.LeftButton)
# No signal emitted, color unchanged
assert cb.color == original_color
# Additional tests for color property getter/setter
def test_color_property_getter_setter_hex(qtbot):
"""Verify the color property works correctly with hex strings."""
cb = create_widget(qtbot, ColorButtonNative)
# Confirm default value is a valid hex string
default_color = cb.color
assert (
isinstance(default_color, str) and default_color.startswith("#") and len(default_color) == 7
)
# Use property setter with a new hex color
new_color_hex = "#123456"
with qtbot.waitSignal(cb.color_changed, timeout=1000):
cb.color = new_color_hex
# Getter should reflect the new value
assert cb.color == new_color_hex
# Button text should update as well
assert cb.text() == new_color_hex
def test_color_property_setter_qcolor(qtbot):
"""Verify the color property accepts QColor and emits the signal."""
cb = create_widget(qtbot, ColorButtonNative)
q_color = QColor(200, 100, 50)
with qtbot.waitSignal(cb.color_changed, timeout=1000):
cb.color = q_color
assert cb.color == q_color.name()
assert cb.text() == q_color.name()

View File

@@ -1,65 +0,0 @@
import sys
import threading
import time
import pytest
from pygments.token import Token
from qtpy.QtCore import QEventLoop
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.editors.console.console import BECConsole
@pytest.fixture
def console_widget(qtbot):
apply_theme("light")
console = BECConsole()
console.set_cmd(sys.executable) # will launch Python interpreter
console.set_prompt_tokens((Token.Prompt, ">>>"))
qtbot.addWidget(console)
console.show()
qtbot.waitExposed(console)
yield console
console.terminate()
def test_console_widget(console_widget, qtbot, tmp_path):
def wait_prompt(command_to_execute=None, busy=False):
signal_waiter = QEventLoop()
def exit_loop(idle):
if busy and not idle:
signal_waiter.quit()
elif not busy and idle:
signal_waiter.quit()
console_widget.prompt.connect(exit_loop)
if command_to_execute:
if callable(command_to_execute):
command_to_execute()
else:
console_widget.execute_command(command_to_execute)
signal_waiter.exec_()
console_widget.start()
wait_prompt()
# use console to write something to a tmp file
tmp_filename = str(tmp_path / "console_test.txt")
wait_prompt(f"f = open('{tmp_filename}', 'wt'); f.write('HELLO CONSOLE'); f.close()")
# check the code has been executed by console, by checking the tmp file contents
with open(tmp_filename, "rt") as f:
assert f.read() == "HELLO CONSOLE"
# execute a sleep
t0 = time.perf_counter()
wait_prompt("import time; time.sleep(1)")
assert time.perf_counter() - t0 >= 1
# test ctrl-c
t0 = time.perf_counter()
wait_prompt("time.sleep(5)", busy=True)
wait_prompt(console_widget.send_ctrl_c)
assert (
time.perf_counter() - t0 < 1
) # in reality it will be almost immediate, but ok we can say less than 1 second compared to 5

View File

@@ -236,7 +236,7 @@ def test_update_coord_label_1D(plot_widget_with_crosshair):
# Provide a test position
pos = (10, 20)
crosshair.update_coord_label(pos)
expected_text = f"({10:.3g}, {20:.3g})"
expected_text = f"({10:.3f}, {20:.3f})"
# Verify that the coordinate label shows only the 1D coordinates (no intensity line)
assert crosshair.coord_label.toPlainText() == expected_text
label_pos = crosshair.coord_label.pos()
@@ -260,10 +260,54 @@ def test_update_coord_label_2D(image_widget_with_crosshair):
ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0
iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1
intensity = known_image[ix, iy] # Expected: 20
expected_text = f"({0.5:.3g}, {1.2:.3g})\nIntensity: {intensity:.3g}"
expected_text = f"({0.5:.3f}, {1.2:.3f})\nIntensity: {intensity:.3f}"
assert crosshair.coord_label.toPlainText() == expected_text
label_pos = crosshair.coord_label.pos()
assert np.isclose(label_pos.x(), 0.5)
assert np.isclose(label_pos.y(), 1.2)
assert crosshair.coord_label.isVisible()
def test_crosshair_precision_properties(plot_widget_with_crosshair):
"""
Ensure Crosshair.precision and Crosshair.min_precision behave correctly
and that _current_precision() reflects changes immediately.
"""
crosshair, plot_item = plot_widget_with_crosshair
assert crosshair.precision == 3
assert crosshair._current_precision() == 3
crosshair.precision = None
plot_item.vb.setXRange(0, 1_000, padding=0)
plot_item.vb.setYRange(0, 1_000, padding=0)
assert crosshair._current_precision() == crosshair.min_precision == 2 # default floor
crosshair.min_precision = 5
assert crosshair._current_precision() == 5
crosshair.precision = 1
assert crosshair._current_precision() == 1
def test_crosshair_precision_properties_image(image_widget_with_crosshair):
"""
The same precision/min_precision behaviour must apply for crosshairs attached
to ImageItem-based plots.
"""
crosshair, plot_item = image_widget_with_crosshair
assert crosshair.precision == 3
assert crosshair._current_precision() == 3
crosshair.precision = None
plot_item.vb.setXRange(0, 1_000, padding=0)
plot_item.vb.setYRange(0, 1_000, padding=0)
assert crosshair._current_precision() == crosshair.min_precision == 2
crosshair.min_precision = 6
assert crosshair._current_precision() == 6
crosshair.precision = 2
assert crosshair._current_precision() == 2

View File

@@ -5,13 +5,20 @@ import pytest
from qtpy.QtCore import QPoint, Qt
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItemForm
from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.services.device_browser import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
@pytest.fixture
@@ -30,22 +37,24 @@ def test_device_browser_init_with_devices(device_browser):
assert device_list.count() == len(device_browser.dev)
def test_device_browser_filtering(qtbot, device_browser):
@pytest.mark.parametrize(
["search_term", "expected_num_visible"],
[("sam", 3), ("nonexistent", 0), ("", -1), (r"(\)", -1)],
)
def test_device_browser_filtering(
qtbot, device_browser, search_term: str, expected_num_visible: int
):
"""
Test that the device browser is able to filter the device list.
"""
device_list = device_browser.ui.device_list
device_browser.ui.filter_input.setText("sam")
qtbot.wait(1000)
assert device_list.count() == 3
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
device_browser.ui.filter_input.setText("nonexistent")
qtbot.wait(1000)
assert device_list.count() == 0
def num_visible(item_dict):
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
device_browser.ui.filter_input.setText("")
qtbot.wait(1000)
assert device_list.count() == len(device_browser.dev)
device_browser.ui.filter_input.setText(search_term)
qtbot.wait(100)
assert num_visible(device_browser._device_items) == expected
def test_device_item_mouse_press_event(device_browser, qtbot):
@@ -55,7 +64,38 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget.label, Qt.MouseButton.LeftButton)
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
def test_update_event_captured(device_browser, qtbot):
device_browser.update_device_list = mock.MagicMock()
device_browser.update_device_list.assert_not_called()
device_browser.on_device_update("remove", {})
device_browser.update_device_list.assert_called_once()
device_browser.on_device_update("", {})
def test_device_item_expansion(device_browser, qtbot):
"""
Test that the form is displayed when the item is expanded, and that the expansion is triggered
by clicking on the expansion button, the title, or the device icon
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
form = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: isinstance(form, DeviceItemForm), timeout=500)
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
assert name_field.getValue() == "samx"
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: widget.expanded, timeout=500)
qtbot.mouseClick(widget._title_icon, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: not widget.expanded, timeout=500)
def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot):
@@ -67,7 +107,7 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
qtbot.mousePress(widget.label, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
qtbot.mousePress(widget._title, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
qtbot.mouseMove(widget, pos=QPoint(10, 10))
qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton)
mock_set_mimedata.assert_called_once()

View File

@@ -67,7 +67,7 @@ def test_device_signal_combo(qtbot, mocked_client):
def test_device_signal_base_init(device_signal_base):
"""Test if the DeviceSignalInputBase is initialized correctly"""
assert device_signal_base._device is None
assert device_signal_base._signal_filter == []
assert device_signal_base._signal_filter == set()
assert device_signal_base._signals == []
assert device_signal_base._hinted_signals == []
assert device_signal_base._normal_signals == []
@@ -76,12 +76,22 @@ def test_device_signal_base_init(device_signal_base):
def test_device_signal_qproperties(device_signal_base):
"""Test if the DeviceSignalInputBase has the correct QProperties"""
assert device_signal_base._signal_filter == set()
device_signal_base.include_config_signals = False
device_signal_base.include_normal_signals = False
assert device_signal_base._signal_filter == set()
device_signal_base.include_config_signals = True
assert device_signal_base._signal_filter == [Kind.config]
assert device_signal_base._signal_filter == {Kind.config}
device_signal_base.include_normal_signals = True
assert device_signal_base._signal_filter == [Kind.config, Kind.normal]
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
device_signal_base.include_hinted_signals = True
assert device_signal_base._signal_filter == [Kind.config, Kind.normal, Kind.hinted]
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
device_signal_base.include_hinted_signals = True
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
device_signal_base.include_hinted_signals = True
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
device_signal_base.include_hinted_signals = False
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
def test_device_signal_set_device(device_signal_base):
@@ -123,7 +133,7 @@ def test_signal_combobox(qtbot, device_signal_combobox):
assert device_signal_combobox._hinted_signals == ["fake_signal"]
def test_signal_lineeidt(device_signal_line_edit):
def test_signal_lineedit(device_signal_line_edit):
"""Test the signal_combobox"""
assert device_signal_line_edit._signals == []

View File

@@ -99,7 +99,7 @@ def test_client_generator_with_black_formatting():
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

@@ -48,12 +48,22 @@ class MyWidget(QWidget):
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent)"""
super(QWidget, self).__init__(parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent=parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(
parent=parent,
other=arguments,
)
""",
]
)

View File

@@ -0,0 +1,60 @@
import sys
from typing import Literal
import pytest
from pydantic import ValidationError
from pydantic.fields import FieldInfo
from bec_widgets.utils.forms_from_types.items import FormItemSpec
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
@pytest.mark.parametrize(
["input", "validity"],
[
({}, False),
({"item_type": int, "name": "test", "info": FieldInfo(), "pretty_display": True}, True),
(
{
"item_type": dict[dict, dict],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
(
{
"item_type": dict[str, str],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", "b"],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", 2],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
],
)
def test_form_item_spec(input, validity):
if validity:
assert FormItemSpec.model_validate(input)
else:
with pytest.raises(ValidationError):
FormItemSpec.model_validate(input)

View File

@@ -0,0 +1,80 @@
from unittest import mock
import pyqtgraph as pg
import pytest
from bec_widgets.widgets.plots.image.image_base import ImageLayerManager
from bec_widgets.widgets.plots.image.image_item import ImageItem
@pytest.fixture()
def image_layer_manager():
"""Fixture to create an instance of ImageLayer."""
layer = ImageLayerManager(parent=None, plot_item=mock.MagicMock(spec=pg.PlotItem))
yield layer
layer.clear()
def test_image_layer_manager_initialization(image_layer_manager):
"""Test the initialization of the ImageLayer."""
assert isinstance(image_layer_manager, ImageLayerManager)
assert image_layer_manager.plot_item is not None
def test_add_image_layer(image_layer_manager):
"""Test adding an image layer to the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert layer.image.zValue() == 0
layer2 = image_layer_manager.add(name="Test Layer 2")
assert layer2.image.zValue() == 1
layer3 = image_layer_manager.add(name="Test Layer 3", z_position="top")
assert layer3.image.zValue() == 2
layer4 = image_layer_manager.add(name="Test Layer 4", z_position="bottom")
assert layer4.image.zValue() == -1
def test_remove_image_layer(image_layer_manager):
"""Test removing an image layer from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.remove(layer)
assert len(image_layer_manager) == 0
def test_clear_image_layers(image_layer_manager):
"""Test clearing all image layers from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.clear()
assert len(image_layer_manager) == 0
def test_image_layer_manager_getitem(image_layer_manager):
"""Test getting an image layer by index."""
layer = image_layer_manager.add(name="Test Layer")
assert image_layer_manager["Test Layer"] == layer
with pytest.raises(TypeError):
_ = image_layer_manager[1]
image_layer_manager.remove("Test Layer")
assert len(image_layer_manager) == 0
def test_image_layer_iteration(image_layer_manager):
"""Test iterating over image layers."""
layer = image_layer_manager.add(name="Test Layer")
assert list(image_layer_manager) == [layer]
layer2 = image_layer_manager.add(name="Test Layer 2")
assert list(image_layer_manager) == [layer, layer2]
layer3 = image_layer_manager.add()
assert list(image_layer_manager) == [layer, layer2, layer3]
names = list(image_layer_manager.layers.keys())
assert names == ["Test Layer", "Test Layer 2", "image_layer_0"]

View File

@@ -1,6 +1,7 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from tests.unit_tests.client_mocks import mocked_client
@@ -61,8 +62,8 @@ def test_lock_aspect_ratio(qtbot, mocked_client):
def test_set_vrange(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.vrange = (10, 100)
assert bec_image_view.vrange == (10, 100)
bec_image_view.v_range = (10, 100)
assert bec_image_view.v_range == QPointF(10, 100)
assert bec_image_view.main_image.levels == (10, 100)
assert bec_image_view.main_image.config.v_range == (10, 100)
@@ -107,7 +108,7 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
assert bec_image_view.enable_full_colorbar is True
assert bec_image_view.config.color_bar == colorbar_type
assert bec_image_view.vrange == (0, 100)
assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view._color_bar is not None
@@ -116,8 +117,8 @@ def test_image_setup_image_2d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -126,8 +127,8 @@ def test_image_setup_image_1d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="1d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_1d"
assert bec_image_view.main_image.config.monitor_type == "1d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_1d"
assert bec_image_view.subscriptions["main"].monitor_type == "1d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -136,8 +137,8 @@ def test_image_setup_image_auto(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="auto")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "auto"
assert bec_image_view.main_image.config.monitor_type == "auto"
assert bec_image_view.subscriptions["main"].source == "auto"
assert bec_image_view.subscriptions["main"].monitor_type == "auto"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -150,7 +151,7 @@ def test_image_data_update_2d(qtbot, mocked_client):
bec_image_view.on_image_update_2d(message, metadata)
np.testing.assert_array_equal(bec_image_view._main_image.image, test_data)
np.testing.assert_array_equal(bec_image_view.main_image.image, test_data)
def test_image_data_update_1d(qtbot, mocked_client):
@@ -160,10 +161,10 @@ def test_image_data_update_1d(qtbot, mocked_client):
metadata = {"scan_id": "scan_test"}
bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
assert bec_image_view._main_image.raw_data.shape == (1, 50)
assert bec_image_view.main_image.raw_data.shape == (1, 50)
bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
assert bec_image_view._main_image.raw_data.shape == (2, 60)
assert bec_image_view.main_image.raw_data.shape == (2, 60)
def test_toolbar_actions_presence(qtbot, mocked_client):
@@ -207,8 +208,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
elif colorbar_type == "full":
bec_image_view.enable_full_colorbar = True
bec_image_view.vrange = (0, 100)
assert bec_image_view.vrange == (0, 100)
bec_image_view.v_range = (0, 100)
assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view.main_image.config.v_range == (0, 100)
assert bec_image_view.v_min == 0
@@ -234,8 +235,8 @@ def test_setup_image_from_toolbar(qtbot, mocked_client):
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None

View File

@@ -4,11 +4,12 @@
# pylint: disable=protected-access
from collections import deque
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel._util import (
log_time,
@@ -65,7 +66,6 @@ def log_panel(qtbot, mocked_client: MagicMock):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.cleanup()
def test_log_panel_init(log_panel: LogPanel):
@@ -97,14 +97,13 @@ def test_logpanel_output(qtbot, log_panel: LogPanel):
return len(log_panel._log_manager._display_queue) == 0
next_text = "datetime | error | test log message"
msg = LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
log_panel._log_manager._process_incoming_log_msg(
{
"data": LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
}
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
qtbot.waitUntil(display_queue_empty, timeout=5000)
@@ -136,3 +135,35 @@ def test_timestamp_filter(log_panel: LogPanel):
assert not filter_(TEST_LOG_MESSAGES[0])
assert filter_(TEST_LOG_MESSAGES[1])
assert not filter_(TEST_LOG_MESSAGES[2])
def test_error_handling_in_callback(log_panel: LogPanel):
log_panel._log_manager.new_message = MagicMock()
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
# generally errors should be logged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=ValueError("Something went wrong")
)
msg = LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()
# this specific error should be ignored and not relogged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()

View File

@@ -349,3 +349,39 @@ def test_enable_fps_monitor_property(qtbot, mocked_client):
pb.enable_fps_monitor = False
assert pb.fps_monitor is None
def test_minimal_crosshair_precision_default(qtbot, mocked_client):
"""
By default PlotBase should expose a floor of 3 decimals, with no crosshair yet.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
assert pb.minimal_crosshair_precision == 3
assert pb.crosshair is None
def test_minimal_crosshair_precision_before_hook(qtbot, mocked_client):
"""
If the floor is changed before hook_crosshair(), the new crosshair must pick it up.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.minimal_crosshair_precision = 5
pb.hook_crosshair()
assert pb.crosshair is not None
assert pb.crosshair.min_precision == 5
def test_minimal_crosshair_precision_after_hook(qtbot, mocked_client):
"""
Changing the floor after the crosshair exists should update it immediately
and emit the property_changed signal.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.hook_crosshair()
assert pb.crosshair is not None
with qtbot.waitSignal(pb.property_changed, timeout=500) as sig:
pb.minimal_crosshair_precision = 1
assert sig.args == ["minimal_crosshair_precision", 1]
assert pb.crosshair.min_precision == 1

View File

@@ -0,0 +1,80 @@
from decimal import Decimal
import pytest
from pydantic import BaseModel, Field
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.utils.forms_from_types.items import (
FloatDecimalMetadataField,
IntMetadataField,
StrMetadataField,
)
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
class ExampleSchema(BaseModel):
str_optional: str | None = Field(
None, title="Optional string", description="an optional string", max_length=23
)
str_required: str
bool_optional: bool | None = Field(None)
bool_required_default: bool = Field(True)
bool_required_nodefault: bool = Field()
int_default: int = Field(123)
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5)
TEST_DICT = {
"sample_name": "test name",
"str_optional": "None",
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": 123.456,
"decimal_dp_limits_nodefault": 34.5,
}
@pytest.fixture
def example_md():
return ExampleSchema.model_validate(TEST_DICT)
@pytest.fixture
def model_widget(qtbot):
widget = PydanticModelForm(data_model=ExampleSchema)
widget.populate()
qtbot.addWidget(widget)
yield widget
def test_widget_dict(model_widget: PydanticModelForm):
assert isinstance(model_widget.widget_dict["str_optional"], StrMetadataField)
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalMetadataField)
assert isinstance(model_widget.widget_dict["int_default"], IntMetadataField)
def test_widget_set_data(model_widget: PydanticModelForm):
data = ExampleSchema.model_validate(TEST_DICT)
model_widget.set_data(data)
for key in [
"str_optional",
"str_required",
"bool_optional",
"bool_required_default",
"bool_required_nodefault",
"int_default",
"int_nodefault_optional",
"float_nodefault",
"decimal_dp_limits_nodefault",
]:
assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]

View File

@@ -7,6 +7,7 @@ from bec_widgets.cli import client
from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
from bec_widgets.widgets.containers.dock.dock import BECDock
@@ -21,7 +22,12 @@ class _TestPluginWidget(BECWidget): ...
@patch(
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widgets",
return_value={"DeviceComboBox": _TestPluginWidget, "NewPluginWidget": _TestPluginWidget},
return_value=BECClassContainer(
[
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""),
]
),
)
def test_duplicate_plugins_not_allowed(_):
handler = RPCWidgetHandler()

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtCore import QModelIndex, Qt
from bec_widgets.utils.forms_from_types.items import StrMetadataField
from bec_widgets.utils.widget_io import WidgetIO

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from typing import Set
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
@@ -8,6 +9,7 @@ from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import (
BoolMetadataField,
DictMetadataField,
DynamicFormItem,
FloatDecimalMetadataField,
IntMetadataField,
@@ -34,12 +36,13 @@ class ExampleSchema(BasicScanMetadata):
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
unsupported_class: Json = Field(default_factory=dict)
dict_default: dict = Field(default_factory=dict)
unsupported_class: Json = Field(default=set())
TEST_DICT = {
"sample_name": "test name",
"str_optional": None,
"str_optional": "None",
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
@@ -47,8 +50,9 @@ TEST_DICT = {
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34),
"unsupported_class": '{"key": "value"}',
"decimal_dp_limits_nodefault": pytest.approx(34.5),
"dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
}
@@ -58,12 +62,11 @@ def example_md():
@pytest.fixture
def empty_metadata_widget():
def empty_metadata_widget(qtbot):
widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
qtbot.addWidget(widget)
yield widget
widget._clear_grid()
widget.deleteLater()
@pytest.fixture
@@ -82,7 +85,8 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(10, 1).widget()
dict_default = widget._form_grid.layout().itemAtPosition(10, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(11, 1).widget()
yield (
widget,
@@ -97,6 +101,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
"int_nodefault_optional": int_nodefault_optional,
"float_nodefault": float_nodefault,
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
"dict_default": dict_default,
"unsupported_class": unsupported_class,
},
)
@@ -112,7 +117,8 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1)
components["decimal_dp_limits_nodefault"].setValue(456.789)
components["unsupported_class"].setValue(r'{"key": "value"}')
components["dict_default"].setValue({"test_dict": "values"})
components["unsupported_class"].setValue(r'["set", "item"]')
def test_griditems_are_correct_class(
@@ -129,6 +135,7 @@ def test_griditems_are_correct_class(
assert isinstance(components["int_nodefault_optional"], IntMetadataField)
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["dict_default"], DictMetadataField)
assert isinstance(components["unsupported_class"], StrMetadataField)
@@ -168,14 +175,16 @@ def test_numbers_clipped_to_limits(
fill_commponents(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
widget.validate_form()
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
assert widget._validity_message.text() == "No errors!"
@pytest.fixture
def table():
table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
table = DictBackedTable(
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
)
yield table
table._table_model.deleteLater()
table._table_view.deleteLater()

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QDialogButtonBox, QLabel
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
from .client_mocks import mocked_client
SAMX_INFO_DICT = {
"signals": {
"readback": {
"component_name": "readback",
"obj_name": "samx",
"kind_int": 5,
"kind_str": "hinted",
"doc": "",
"describe": {"source": "SIM:samx", "dtype": "integer", "shape": [], "precision": 3},
"metadata": {
"connected": True,
"read_access": True,
"write_access": False,
"timestamp": 123456.789,
"status": None,
"severity": None,
"precision": None,
},
}
},
"setpoint": {
"component_name": "setpoint",
"obj_name": "samx_setpoint",
"kind_int": 1,
"kind_str": "normal",
"doc": "",
"describe": {
"source": "SIM:samx_setpoint",
"dtype": "integer",
"shape": [],
"precision": 3,
},
"metadata": {
"connected": True,
"read_access": True,
"write_access": True,
"timestamp": 1747657955.012516,
"status": None,
"severity": None,
"precision": None,
},
},
}
@pytest.fixture
def signal_label(qtbot, mocked_client: MagicMock):
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
config = DeviceSignalInputBaseConfig(device="samx", default="samx")
widget = SignalLabel(
config=config, custom_label="Test Label", custom_units="m/s", client=mocked_client
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.show()
yield widget
def test_initialization(signal_label: SignalLabel):
"""Test the initialization of the SignalLabel widget."""
assert signal_label.device == "Not set!"
assert signal_label.custom_label == "Test Label"
assert signal_label.custom_units == "m/s"
assert signal_label.show_select_button is True
assert signal_label.show_default_units is False
assert signal_label.decimal_places == 3
signal_label.set_display_value()
assert signal_label._display.text() == ""
def test_initialization_with_device(qtbot, mocked_client: MagicMock):
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
widget = SignalLabel(device="samx", signal="readback", client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.show()
assert widget._label.title() == "samx readback:"
def test_set_display_value(signal_label: SignalLabel, qtbot):
qtbot.addWidget(signal_label)
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.456 m/s"
def test_show_select_button(signal_label: SignalLabel, qtbot):
assert signal_label.show_select_button == True
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
signal_label.show_select_button = False
qtbot.waitUntil(lambda: not signal_label._select_button.isVisible(), timeout=1000)
signal_label.show_select_button = True
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
def test_show_default_units(signal_label: SignalLabel, qtbot):
signal_label.show_default_units = True
assert signal_label.show_default_units is True
signal_label.show_default_units = False
assert signal_label.show_default_units is False
def test_custom_label(signal_label: SignalLabel, qtbot):
signal_label.custom_label = "New Label"
assert signal_label._label.title() == "New Label"
def test_units_in_display(signal_label: SignalLabel, qtbot):
signal_label._value = "1.8"
signal_label.custom_units = "Mfurlong μfortnight⁻¹"
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
def test_decimal_places(signal_label: SignalLabel, qtbot):
signal_label.decimal_places = 2
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.46 m/s"
signal_label.decimal_places = 0
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.456 m/s"
def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["test device"] = MagicMock()
dialog._device_field.setText("test device")
dialog._signal_field.addItem("test signal")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
signal_label._process_dialog.assert_called_once_with("test device", "test signal")
def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot):
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["flux_capacitor"] = MagicMock()
dialog._device_field.setText("flux_capacitor")
dialog._signal_field.addItem("spin_speed")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
assert signal_label._device == "flux_capacitor"
assert signal_label._signal == "spin_speed"
def test_choose_signal_dialog_invalid_device(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.setText("invalid device")
dialog._signal_field.addItem("test signal")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.wait(100)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Cancel), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
signal_label._process_dialog.assert_not_called()
def test_choice_dialog_with_no_client(qtbot):
dialog = ChoiceDialog()
qtbot.addWidget(dialog)
assert dialog.button_box.button(QDialogButtonBox.Ok) is None
assert dialog.button_box.button(QDialogButtonBox.Cancel) is not None
assert dialog.layout().itemAt(0).widget().text() == ChoiceDialog.CONNECTION_ERROR_STR
def test_dialog_has_signals(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["test device"] = MagicMock()
dialog._device_field.dev["test device"]._info = {
"signals": {"signal 1": {"kind_str": "hinted"}, "signal 2": {"kind_str": "normal"}}
}
dialog._device_field.setText("test device")
assert dialog._signal_field.count() == 2 # the actual signal and the category label
assert dialog._signal_field.currentText() == "signal 1"
def test_set_existing_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
assert signal_label._device == "samx"
assert signal_label._config.device == "samx"
assert signal_label._signal == "readback"
assert signal_label._config.default == "readback"
def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.custom_units = ""
signal_label.device = "samq"
signal_label.signal = "readfront"
assert signal_label._device == "samq"
assert signal_label._config.device == "samq"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
assert signal_label._signal == "readfront"
assert signal_label._config.default == "readfront"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
def test_handle_readback(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
signal_label.custom_units = "μm"
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
assert signal_label._display.text() == "ERROR!"
assert "Error processing incoming reading" in signal_label._display.toolTip()
signal_label.on_device_readback(
{
"signals": {
"samx": {"value": 0.9927490347496489, "timestamp": 1747662246.3741279},
"samx_setpoint": {"value": 1.0, "timestamp": 1747662246.368704},
"samx_motor_is_moving": {"value": 0, "timestamp": 1747662246.373092},
}
},
{},
)
assert signal_label._display.text() == "0.993 μm"
assert signal_label._display.toolTip() == ""

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest import mock
@@ -7,6 +9,15 @@ import numpy as np
import pyqtgraph as pg
import pytest
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QSpinBox,
)
from bec_widgets.widgets.plots.plot_base import UIMode
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
@@ -533,6 +544,7 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.scan_item = create_dummy_scan_item()
wf._scan_done = False # simulate a live scan
c = wf.plot(arg1="async_device", label="async_device-async_device")
wf._async_curves = [c]
# Suppose existing data
@@ -819,3 +831,227 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
wf.dap_summary_dialog.close()
assert wf.dap_summary_dialog is None
assert fit_action.isChecked() is False
#####################################################
# The following tests are for the async dataset guard
#####################################################
def test_skip_large_dataset_warning_property(qtbot, mocked_client):
"""
Verify the getter and setter of skip_large_dataset_warning work correctly.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Default should be False
assert wf.skip_large_dataset_warning is False
# Set to True
wf.skip_large_dataset_warning = True
assert wf.skip_large_dataset_warning is True
# Toggle back to False
wf.skip_large_dataset_warning = False
assert wf.skip_large_dataset_warning is False
def test_max_dataset_size_mb_property(qtbot, mocked_client):
"""
Verify getter, setter, and validation of max_dataset_size_mb.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Default from WaveformConfig is 1 MB
assert wf.max_dataset_size_mb == 10
# Set to a valid new value
wf.max_dataset_size_mb = 5.5
assert wf.max_dataset_size_mb == 5.5
# Ensure the config is updated too
assert wf.config.max_dataset_size_mb == 5.5
def _dummy_dataset(mem_bytes: int, entry: str = "waveform_waveform"):
"""
Return an object that mimics the BEC dataset structure:
it has exactly one attribute `_info` with the expected layout.
"""
return SimpleNamespace(_info={entry: {"value": {"mem_size": mem_bytes}}})
def test_dataset_guard_under_limit(qtbot, mocked_client, monkeypatch):
"""
Dataset below the limit should load without triggering the dialog.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1 # 1 MiB
# If the dialog is called, we flip this flag it must stay False.
called = {"dlg": False}
monkeypatch.setattr(
Waveform, "_confirm_large_dataset", lambda self, size_mb: called.__setitem__("dlg", True)
)
dataset = _dummy_dataset(mem_bytes=512_000) # ≈0.49 MiB
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
assert called["dlg"] is False
def test_dataset_guard_over_limit_accept(qtbot, mocked_client, monkeypatch):
"""
Dataset above the limit where user presses *Yes*.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1 # 1 MiB
# Pretend the user clicked “Yes”
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: True)
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
def test_dataset_guard_over_limit_reject(qtbot, mocked_client, monkeypatch):
"""
Dataset above the limit where user presses *No*.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1 # 1 MiB
# Pretend the user clicked “No”
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: False)
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is False
##################################################
# Dialog propagation behaviour
##################################################
def test_dialog_accept_updates_limit(monkeypatch, qtbot, mocked_client):
"""
Simulate clicking 'Yes' in the dialog *after* changing the spinner value.
Verify max_dataset_size_mb is updated and dataset loads.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1 # start small
def fake_confirm(self, size_mb):
# Simulate user typing '5' in the spinbox then pressing Yes
self.config.max_dataset_size_mb = 5
return True # Yes pressed
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
big_dataset = _dummy_dataset(mem_bytes=4_800_000) # ≈4.6 MiB
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
# The load should be accepted and the limit must reflect the new value
assert accepted is True
assert wf.max_dataset_size_mb == 5
assert wf.config.max_dataset_size_mb == 5
def test_dialog_cancel_sets_skip(monkeypatch, qtbot, mocked_client):
"""
Simulate clicking 'No' but ticking 'Don't show again'.
Verify skip_large_dataset_warning becomes True and dataset is skipped.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
assert wf.skip_large_dataset_warning is False
def fake_confirm(self, size_mb):
# Mimic ticking the checkbox then pressing No
self._skip_large_dataset_warning = True
return False # No pressed
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
big_dataset = _dummy_dataset(mem_bytes=11_000_000)
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
# Dataset must not load, but future warnings are suppressed
assert accepted is False
assert wf.skip_large_dataset_warning is True
##################################################
# Live dialog interaction (no monkeypatching)
##################################################
def _open_dialog_and_click(handler):
"""
Utility that schedules *handler* to run as soon as a modal
dialog is shown. Returns a function suitable for QTimer.singleShot.
"""
def _cb():
# Locate the active modal dialog
dlg = QApplication.activeModalWidget()
assert isinstance(dlg, QDialog), "No active modal dialog found"
handler(dlg)
return _cb
def test_dialog_accept_real_interaction(qtbot, mocked_client):
"""
Endtoend: user changes the limit spinner to 5 MiB, ticks
'don't show again', then presses YES.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1
# Prepare a large dataset (≈4.6MiB)
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
def handler(dlg):
spin: QDoubleSpinBox = dlg.findChild(QDoubleSpinBox)
chk: QCheckBox = dlg.findChild(QCheckBox)
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
# # Interact with widgets
spin.setValue(5)
chk.setChecked(True)
yes_btn = btns.button(QDialogButtonBox.Yes)
yes_btn.click()
# Schedule the handler right before invoking the check
QTimer.singleShot(0, _open_dialog_and_click(handler))
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
assert accepted is True
assert wf.max_dataset_size_mb == 5
assert wf.skip_large_dataset_warning is True
def test_dialog_reject_real_interaction(qtbot, mocked_client):
"""
Endtoend: user leaves spinner unchanged, ticks 'don't show again',
and presses NO.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.max_dataset_size_mb = 1
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
def handler(dlg):
chk: QCheckBox = dlg.findChild(QCheckBox)
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
chk.setChecked(True)
no_btn = btns.button(QDialogButtonBox.No)
no_btn.click()
QTimer.singleShot(0, _open_dialog_and_click(handler))
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
assert accepted is False
assert wf.skip_large_dataset_warning is True
# Limit remains unchanged
assert wf.max_dataset_size_mb == 1