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

Compare commits

...

44 Commits

Author SHA1 Message Date
edd3038e10 feat(curve, waveform): add dap_parameters for lmfit customization in DAP requests 2025-12-16 14:05:21 +01:00
7993cf9c1d wip cleanup 2025-12-16 13:58:53 +01:00
40e186cb13 fix(colors): more benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping from GradientWidget from pg 2025-12-16 13:58:53 +01:00
semantic-release
ca6f355aac 2.45.12
Automatically generated by python-semantic-release
2025-12-16 12:37:06 +00:00
d876ca72bc fix(heatmap): more robust logic for fast and slow axis in grid scan 2025-12-16 13:36:17 +01:00
e0fd97616d fix(heatmap): flush image if config changes during scan 2025-12-16 13:36:17 +01:00
6af8a5cbfe fix(heatmap): grid scan image correctly map to scan positions 2025-12-16 13:36:17 +01:00
semantic-release
944e2cedf8 2.45.11
Automatically generated by python-semantic-release
2025-12-15 14:48:20 +00:00
cd11a6cce3 fix(waveform): support for AsyncMultiSignal 2025-12-15 15:47:26 +01:00
semantic-release
c98106e594 2.45.10
Automatically generated by python-semantic-release
2025-12-10 11:21:40 +00:00
04f1ff4fe7 fix(devices): minor fix to comply with new config helper in bec_lib 2025-12-10 12:20:48 +01:00
semantic-release
45ed92494c 2.45.9
Automatically generated by python-semantic-release
2025-12-09 14:21:27 +00:00
5fc96bd299 fix(rpc): add expiration to GUI registry state updates 2025-12-09 15:20:42 +01:00
semantic-release
1ad5df57fe 2.45.8
Automatically generated by python-semantic-release
2025-12-08 14:36:13 +00:00
440e778162 fix(notification_banner): backwards compatibility to push messages from Broker to Centre as dict 2025-12-08 15:35:23 +01:00
fdeb8fcb0f fix(notification_banner): formatted error messages fetched directly from BECMessage; do not repreat notifications ids 2025-12-08 15:35:23 +01:00
5c90983dd4 fix(notification_banner): better contrast in light mode 2025-12-08 15:35:23 +01:00
4171de1e45 fix(notification_banner): expired messages are hidden in notification center but still accessible 2025-12-08 15:35:23 +01:00
semantic-release
f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release
7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release
cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
semantic-release
9bc9d355e2 2.45.4
Automatically generated by python-semantic-release
2025-11-24 13:30:46 +00:00
7d5e702a11 fix(web_links): fixed link to bec widget issues from gitlab to github 2025-11-24 14:29:56 +01:00
40cbf7fe4f fix(main_window): removed hiding scan progressbar animation 2025-11-24 14:29:56 +01:00
semantic-release
7b287c45f2 2.45.3
Automatically generated by python-semantic-release
2025-11-17 19:27:38 +00:00
c9455672b5 fix(fakeredis): add support for additional args 2025-11-17 20:24:44 +01:00
semantic-release
7f06375f9d 2.45.2
Automatically generated by python-semantic-release
2025-11-17 12:30:21 +00:00
d00d786399 fix(test): removed duplicate test in crosshair 2025-11-17 13:29:35 +01:00
a4c465dcaf build: pyqtgraph pin to 0.13.7 2025-11-17 13:29:35 +01:00
semantic-release
d0e94d0da4 2.45.1
Automatically generated by python-semantic-release
2025-11-14 14:13:05 +00:00
bb3cea7fe8 fix(waveform): async_readback can accept 0D data 2025-11-14 15:12:14 +01:00
semantic-release
3c6aa8e138 2.45.0
Automatically generated by python-semantic-release
2025-11-10 19:28:18 +00:00
198684c65d feat(waveform): dap curve can be attached to custom and history curves 2025-11-10 20:27:31 +01:00
617f2df2af chore: add third-party license notice 2025-11-10 13:52:22 +01:00
semantic-release
ef83287126 2.44.0
Automatically generated by python-semantic-release
2025-11-05 21:43:46 +00:00
d5e6f095fe refactor(plot_base): consolidated user access for the PlotBase 2025-11-05 22:42:57 +01:00
b10efc0f40 feat(plot_base): invert x/y axis 2025-11-05 22:42:57 +01:00
44b1dbf911 docs: README rewritten 2025-11-03 14:59:57 +01:00
Klaus Wakonig
e9d381a18a chore: Update stale issue and PR settings to 120 days 2025-11-03 14:46:03 +01:00
semantic-release
b005542df3 2.43.0
Automatically generated by python-semantic-release
2025-10-30 07:58:54 +00:00
13a9175ba5 feat: add pdf viewer widget 2025-10-30 08:58:11 +01:00
42 changed files with 2766 additions and 643 deletions

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7
stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
days-before-stale: 120
days-before-close: 14

View File

@@ -1,6 +1,170 @@
# CHANGELOG
## v2.45.12 (2025-12-16)
### Bug Fixes
- **heatmap**: Flush image if config changes during scan
([`e0fd976`](https://github.com/bec-project/bec_widgets/commit/e0fd97616d370722e2ebf12d0f93862ac35cb20d))
- **heatmap**: Grid scan image correctly map to scan positions
([`6af8a5c`](https://github.com/bec-project/bec_widgets/commit/6af8a5cbfe0f97327b31039033d3e6946388347c))
- **heatmap**: More robust logic for fast and slow axis in grid scan
([`d876ca7`](https://github.com/bec-project/bec_widgets/commit/d876ca72bc50f967f0872eb777f2378a3db68ddf))
## v2.45.11 (2025-12-15)
### Bug Fixes
- **waveform**: Support for AsyncMultiSignal
([`cd11a6c`](https://github.com/bec-project/bec_widgets/commit/cd11a6cce33f3c0642984ae6b2d159c7441e22c6))
## v2.45.10 (2025-12-10)
### Bug Fixes
- **devices**: Minor fix to comply with new config helper in bec_lib
([`04f1ff4`](https://github.com/bec-project/bec_widgets/commit/04f1ff4fe7869215f010bf73f7271e063e21f2a2))
## v2.45.9 (2025-12-09)
### Bug Fixes
- **rpc**: Add expiration to GUI registry state updates
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
## v2.45.8 (2025-12-08)
### Bug Fixes
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
- **notification_banner**: Better contrast in light mode
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
- **notification_banner**: Expired messages are hidden in notification center but still accessible
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
notifications ids
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
## v2.45.7 (2025-12-08)
### Bug Fixes
- Handle none in literal combobox
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
## v2.45.6 (2025-11-27)
### Bug Fixes
- **curve**: Update dap curves if data are set manually
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
## v2.45.5 (2025-11-26)
### Bug Fixes
- Remove ghost widgets in scan metadata
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
## v2.45.4 (2025-11-24)
### Bug Fixes
- **main_window**: Removed hiding scan progressbar animation
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
- **web_links**: Fixed link to bec widget issues from gitlab to github
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
## v2.45.3 (2025-11-17)
### Bug Fixes
- **fakeredis**: Add support for additional args
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
## v2.45.2 (2025-11-17)
### Bug Fixes
- **test**: Removed duplicate test in crosshair
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
### Build System
- Pyqtgraph pin to 0.13.7
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
## v2.45.1 (2025-11-14)
### Bug Fixes
- **waveform**: Async_readback can accept 0D data
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
## v2.45.0 (2025-11-10)
### Chores
- Add third-party license notice
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
### Features
- **waveform**: Dap curve can be attached to custom and history curves
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
## v2.44.0 (2025-11-05)
### Chores
- Update stale issue and PR settings to 120 days
([`e9d381a`](https://github.com/bec-project/bec_widgets/commit/e9d381a18a425727216f035ecccdad25f3189608))
### Documentation
- Readme rewritten
([`44b1dbf`](https://github.com/bec-project/bec_widgets/commit/44b1dbf911f43dbde4286e2ea541c480f7b834be))
### Features
- **plot_base**: Invert x/y axis
([`b10efc0`](https://github.com/bec-project/bec_widgets/commit/b10efc0f400fe36f7cb0d5998214d50943934d7b))
### Refactoring
- **plot_base**: Consolidated user access for the PlotBase
([`d5e6f09`](https://github.com/bec-project/bec_widgets/commit/d5e6f095fe60223972235acd3ea68389aa7a1a14))
## v2.43.0 (2025-10-30)
### Features
- Add pdf viewer widget
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
## v2.42.1 (2025-10-28)
### Bug Fixes

211
README.md
View File

@@ -1,5 +1,6 @@
# BEC Widgets
![banner_opti](https://github.com/user-attachments/assets/44e483be-3f0d-4eb0-bd98-613157456b81)
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
@@ -10,72 +11,190 @@
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
plugin system.
**⚠️ Important Notice:**
## Highlights
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
a single line of code**. Just launch, drag widgets, and do your experiment.
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: dragdock, tab,
split, and export profiles/workspaces for reuse.
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
`BECIPythonClient`.
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
with the custom BEC loader for a zeroglue workflow.
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
acquisition history.
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
or override behavior as needed.
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
## Table of Contents
- [Installation](#installation)
- [Features](#features)
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
- [Widget Library](#widget-library)
- [Documentation](#documentation)
- [License](#license)
## Installation
Use any of the following setups:
### Stable release
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec_widgets[pyside6]
pip install bec_widgets
```
### From source (recommended for development)
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
git clone https://github.com/bec-project/bec_widgets.git
cd bec_widgets
pip install -e .[dev,pyside6]
pip install -e .[dev]
```
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
specified.
## Features
### 1. Dock area interface: build GUIs in seconds
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
operate immediately and refine later with RPC or Designer if needed.
![dock_area_example](https://github.com/user-attachments/assets/219a2806-19a8-4a07-9734-b7b554850833)
### 2. Qt Designer plugins + BEC Launcher (no glue)
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
visually in Designer, save a `.ui`, then launch it with
the **BEC Launcher**—no glue code. Widgets autoconnect to BEC/Redis on startup, so your UI is operational immediately.
![designer_opti](https://github.com/user-attachments/assets/fed4843c-1cce-438a-b41f-6636fa5e1545)
### 3. Robust RPC from CLI & remote scripting
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
active devices, topics)—so your UI can be heavily automated.
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
actions,switch profiles, open diagnostic views, or start specific scans.
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
### 4. Rapid development (extensible by design)
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
Designer plugin that are immediately usable with your BEC setup. Widgets
come online with live BEC/Redis wiring out of the box.
<details>
<summary> View code: Example Widget </summary>
```python
from typing import Literal
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
from qtpy.QtCore import Slot
from bec_lib.endpoints import MessageEndpoints
from bec_widgets import BECWidget, SafeSlot
class SimpleMotorWidget(BECWidget, QWidget):
USER_ACCESS = ["move"]
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
super().__init__(parent=parent, **kwargs)
self.motor_name = motor_name
self.step = float(step)
self.get_bec_shortcuts()
self.value_label = QLabel(f"{self.motor_name}: —")
self.btn_left = QPushButton("◀︎ -5")
self.btn_right = QPushButton("+5 ▶︎")
row = QHBoxLayout()
row.addWidget(self.btn_left)
row.addWidget(self.btn_right)
col = QVBoxLayout(self)
col.addWidget(self.value_label)
col.addLayout(row)
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
@SafeSlot(dict, dict)
def on_readback(self, data: dict, meta: dict):
current_value = data.get("signals").get(self.motor_name).get('value')
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
@Slot(str, float)
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
if direction == "left":
self.dev[self.motor_name].move(-step, relative=True)
else:
self.dev[self.motor_name].move(step, relative=True)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
w = SimpleMotorWidget(motor_name="samx", step=5.0)
w.setWindowTitle("MotorJogWidget")
w.resize(280, 90)
w.show()
sys.exit(app.exec_())
```
</details>
## Widget Library
A large and growing catalog—plug, configure, run:
### Plotting
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
history data.
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
### Scan orchestration and motion control.
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
Positioner boxes and tweak controls handle precise moves, homing, and calibration for daytoday alignment.
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
> │ │
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
> │ elements|forms|http|language-service|localize|platform-browser|
> │ platform-browser-dynamic|platform-server|router|service-worker|
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
> │ devtools
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
> Must be one of the following:
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
> * **feat**: A new feature
> * **fix**: A bug fix
> * **perf**: A code change that improves performance
> * **refactor**: A code change that neither fixes a bug nor adds a feature
> * **test**: Adding missing tests or correcting existing tests
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

28
THIRD-PARTY-LICENCES Normal file
View File

@@ -0,0 +1,28 @@
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
Core Dependencies:
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
Additional Dependencies (Testing/Development):
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)

View File

@@ -45,6 +45,7 @@ _Widgets = {
"MonacoWidget": "MonacoWidget",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PdfViewerWidget": "PdfViewerWidget",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
@@ -1204,6 +1205,12 @@ class EllipticalROI(RPCBase):
class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -1391,6 +1398,29 @@ class Heatmap(RPCBase):
Show the outer axes of the plot widget.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -1419,6 +1449,48 @@ class Heatmap(RPCBase):
Set auto range for the y-axis.
"""
@property
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@x_log.setter
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@y_log.setter
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@legend_label_size.setter
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
@@ -1496,20 +1568,6 @@ class Heatmap(RPCBase):
Get the maximum value of the v_range.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@property
@rpc_call
def autorange(self) -> "bool":
@@ -1749,6 +1807,12 @@ class Heatmap(RPCBase):
class Image(RPCBase):
"""Image widget for displaying 2D data."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -1936,6 +2000,29 @@ class Image(RPCBase):
Show the outer axes of the plot widget.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -1964,6 +2051,48 @@ class Image(RPCBase):
Set auto range for the y-axis.
"""
@property
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@x_log.setter
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@y_log.setter
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@legend_label_size.setter
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
@@ -2041,20 +2170,6 @@ class Image(RPCBase):
Get the maximum value of the v_range.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@property
@rpc_call
def autorange(self) -> "bool":
@@ -2594,6 +2709,12 @@ class MonacoWidget(RPCBase):
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -2795,6 +2916,15 @@ class MotorMap(RPCBase):
Lock aspect ratio of the plot widget.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -2865,6 +2995,20 @@ class MotorMap(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.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -2992,6 +3136,12 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -3193,6 +3343,15 @@ class MultiWaveform(RPCBase):
Lock aspect ratio of the plot widget.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -3421,6 +3580,137 @@ class MultiWaveform(RPCBase):
"""
class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls."""
@rpc_call
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
@rpc_call
def zoom_in(self):
"""
Zoom in the PDF view.
"""
@rpc_call
def zoom_out(self):
"""
Zoom out the PDF view.
"""
@rpc_call
def fit_to_width(self):
"""
Fit PDF to width.
"""
@rpc_call
def fit_to_page(self):
"""
Fit PDF to page.
"""
@rpc_call
def reset_zoom(self):
"""
Reset zoom to 100% (1.0 factor).
"""
@rpc_call
def previous_page(self):
"""
Go to previous page.
"""
@rpc_call
def next_page(self):
"""
Go to next page.
"""
@rpc_call
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
@property
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@page_spacing.setter
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@property
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@side_margins.setter
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@rpc_call
def go_to_first_page(self):
"""
Go to the first page.
"""
@rpc_call
def go_to_last_page(self):
"""
Go to the last page.
"""
@rpc_call
def jump_to_page(self, page_number: int):
"""
Jump to a specific page number (1-based index).
"""
@property
@rpc_call
def current_page(self):
"""
Get the current page number (1-based index).
"""
@property
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
@current_file_path.setter
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""
@@ -4070,6 +4360,12 @@ class ScatterCurve(RPCBase):
class ScatterWaveform(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -4271,6 +4567,15 @@ class ScatterWaveform(RPCBase):
Lock aspect ratio of the plot widget.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -4689,14 +4994,10 @@ class VSCodeEditor(RPCBase):
class Waveform(RPCBase):
"""Widget for plotting waveforms."""
@property
@rpc_call
def _config_dict(self) -> "dict":
def remove(self):
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
Cleanup the BECConnector
"""
@property
@@ -4900,6 +5201,15 @@ class Waveform(RPCBase):
Lock aspect ratio of the plot widget.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
@@ -4928,15 +5238,6 @@ class Waveform(RPCBase):
Set auto range for the y-axis.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def x_log(self) -> "bool":
@@ -5000,6 +5301,16 @@ class Waveform(RPCBase):
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":
@@ -5107,6 +5418,7 @@ class Waveform(RPCBase):
color: "str | None" = None,
label: "str | None" = None,
dap: "str | None" = None,
dap_parameters: "dict | lmfit.Parameters | None | object" = None,
scan_id: "str | None" = None,
scan_number: "int | None" = None,
**kwargs,
@@ -5128,9 +5440,11 @@ class Waveform(RPCBase):
y_entry(str): The name of the entry for the y-axis.
color(str): The color of the curve.
label(str): The label of the curve.
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
dap(str): The dap model to use for the curve. When provided, a DAP curve is
attached automatically for device, history, or custom data sources. Use
the same string as the LMFit model name.
dap_parameters(dict | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
Values can be numeric (interpreted as fixed parameters) or dicts like`{"value": 1.0, "vary": False}`.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
@@ -5147,17 +5461,20 @@ class Waveform(RPCBase):
dap_name: "str",
color: "str | None" = None,
dap_oversample: "int" = 1,
dap_parameters: "dict | lmfit.Parameters | None" = None,
**kwargs,
) -> "Curve":
"""
Create a new DAP curve referencing the existing device curve `device_label`,
with the data processing model `dap_name`.
Create a new DAP curve referencing the existing curve `device_label`, with the
data processing model `dap_name`. DAP curves can be attached to curves that
originate from live devices, history, or fully custom data sources.
Args:
device_label(str): The label of the device curve to add DAP to.
device_label(str): The label of the source curve to add DAP to.
dap_name(str): The name of the DAP model to use.
color(str): The color of the curve.
dap_oversample(int): The oversampling factor for the DAP curve.
dap_parameters(dict | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
**kwargs
Returns:

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
from functools import lru_cache
import re
from typing import TYPE_CHECKING, Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
@@ -15,6 +17,9 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
logger = bec_logger.logger
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
@@ -138,6 +143,83 @@ def apply_theme(theme: Literal["dark", "light"]):
class Colors:
@staticmethod
def list_available_colormaps() -> list[str]:
"""
List colormap names available via the pyqtgraph colormap registry.
Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus).
"""
def _list(source: str | None = None) -> list[str]:
try:
return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source)
except Exception: # pragma: no cover - backend may be missing
return []
return [*_list(None), *_list("matplotlib"), *_list("colorcet")]
@staticmethod
def list_available_gradient_presets() -> list[str]:
"""
List `GradientEditorItem` preset names (HistogramLUT right-click menu entries).
"""
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
return list(Gradients.keys())
@staticmethod
def canonical_colormap_name(color_map: str) -> str:
"""
Return an available colormap/preset name if a case-insensitive match exists.
"""
requested = (color_map or "").strip()
if not requested:
return requested
registry = Colors.list_available_colormaps()
presets = Colors.list_available_gradient_presets()
available = set(registry) | set(presets)
if requested in available:
return requested
# Case-insensitive match.
lower_to_canonical = {name.lower(): name for name in available}
return lower_to_canonical.get(requested.lower(), requested)
@staticmethod
def get_colormap(color_map: str) -> pg.ColorMap:
"""
Resolve a string into a `pg.ColorMap` using either:
- the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or
- `GradientEditorItem` presets (HistogramLUT right-click menu).
"""
name = Colors.canonical_colormap_name(color_map)
if not name:
raise ValueError("Empty colormap name")
return Colors._get_colormap_cached(name)
@staticmethod
@lru_cache(maxsize=256)
def _get_colormap_cached(name: str) -> pg.ColorMap:
# 1) Registry/backends
try:
return pg.colormap.get(name)
except Exception:
pass
for source in ("matplotlib", "colorcet"):
try:
return pg.colormap.get(name, source=source)
except Exception:
continue
# 2) Presets -> ColorMap
ge = pg.GradientEditorItem()
ge.loadPreset(name)
return ge.colorMap()
@staticmethod
def golden_ratio(num: int) -> list:
@@ -219,7 +301,7 @@ class Colors:
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
cmap = Colors.get_colormap(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
@@ -267,7 +349,7 @@ class Colors:
ValueError: If theme_offset is not between 0 and 1.
"""
cmap = pg.colormap.get(colormap)
cmap = Colors.get_colormap(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
@@ -533,18 +615,21 @@ class Colors:
Raises:
PydanticCustomError: If colormap is invalid.
"""
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
if color_map not in available_colormaps:
normalized = Colors.canonical_colormap_name(color_map)
try:
Colors.get_colormap(normalized)
except Exception as ext:
logger.warning(f"Colormap validation error: {ext}")
if return_error:
available_colormaps = sorted(
set(Colors.list_available_colormaps())
| set(Colors.list_available_gradient_presets())
)
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
return color_map
return normalized

View File

@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid_container.setLayout(QVBoxLayout())
self._layout.addWidget(self._form_grid_container)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label = QLabel(parent=self._form_grid, text=item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1)
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
gl = self._form_grid.layout()
while w := gl.takeAt(0):
w = w.widget()
if hasattr(w, "teardown"):
w.teardown()
w.deleteLater()
self._form_grid_container.layout().removeWidget(self._form_grid)
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid

View File

@@ -3,14 +3,16 @@ from __future__ import annotations
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from types import GenericAlias, NoneType, UnionType
from typing import (
Any,
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
Optional,
OrderedDict,
TypeVar,
get_args,
@@ -71,7 +73,7 @@ class FormItemSpec(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias
item_type: type | UnionType | GenericAlias | Optional[Any]
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
@@ -188,6 +190,10 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@SafeSlot()
def clear(self, *_):
return
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
@@ -204,11 +210,17 @@ class DynamicFormItem(QWidget):
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
self._clear_button.clicked.connect(self.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
def teardown(self):
self._layout.deleteLater()
self._layout.removeWidget(self._main_widget)
self._main_widget.deleteLater()
self._main_widget = None
class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
@@ -542,11 +554,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._layout.addWidget(self._main_widget)
def getValue(self):
if self._main_widget.currentIndex() == -1:
return None
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
return
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
@@ -557,15 +572,39 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1)
class OptionalStrLiteralFormItem(StrLiteralFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(get_args(self._spec.info.annotation)[0])
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
def _is_optional_string_literal(t: type):
if not hasattr(t, "__args__"):
return False
if len(t.__args__) != 2:
return False
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
return True
return False
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
"optional_literal_str": (
lambda spec: _is_optional_string_literal(spec.info.annotation),
OptionalStrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
@@ -616,6 +655,8 @@ if __name__ == "__main__": # pragma: no cover
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
literal: Literal["a", "b", "c"]
nullable_literal: Literal["a", "b", "c"] | None = None
app = QApplication([])
w = QWidget()
@@ -623,7 +664,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)

View File

@@ -229,6 +229,7 @@ class RPCServer:
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
expire=60,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:

View File

@@ -14,13 +14,14 @@ from __future__ import annotations
import json
import sys
from datetime import datetime
from enum import Enum, auto
from enum import Enum
from typing import Literal
from uuid import uuid4
import pyqtgraph as pg
from bec_lib.alarm_handler import Alarms # external enum
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import ErrorInfo
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QObject, QTimer
@@ -28,6 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils import BECConnector
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetIO
@@ -53,10 +55,10 @@ DARK_PALETTE = {
}
LIGHT_PALETTE = {
"base": "#e9ecef",
"title": "#212121",
"body": "#424242",
"separator": "rgba(0,0,0,40)",
"base": "#f5f5f7",
"title": "#111827",
"body": "#374151",
"separator": "rgba(15,23,42,40)",
}
@@ -108,6 +110,7 @@ class NotificationToast(QFrame):
self._kind = kind if isinstance(kind, SeverityKind) else SeverityKind(kind)
self._traceback = traceback
self._accent_color = QtGui.QColor(SEVERITY[self._kind.value]["color"])
self._accent_alpha = 50
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.created = datetime.now()
@@ -379,22 +382,31 @@ class NotificationToast(QFrame):
# buttons (text colour)
base_btn_color = palette["title"]
card_bg = QtGui.QColor(palette["base"])
# tune card background and hover contrast per theme
if theme == "light":
card_bg.setAlphaF(0.98)
btn_hover = self._accent_color.darker(105).name()
else:
card_bg.setAlphaF(0.88)
btn_hover = self._accent_color.name()
self.setStyleSheet(
"""
#NotificationToast {
background: transparent;
f"""
#NotificationToast {{
background: {card_bg.name(QtGui.QColor.HexArgb)};
border-radius: 12px;
color: %s;
}
#NotificationToast QPushButton {
color: {base_btn_color};
border: 1px solid {palette["separator"]};
}}
#NotificationToast QPushButton {{
background: transparent;
border: none;
color: %s;
color: {base_btn_color};
font-size: 14px;
}
#NotificationToast QPushButton:hover { color: %s; }
}}
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
"""
% (base_btn_color, base_btn_color, self._accent_color.name())
)
# traceback panel colours
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
@@ -407,6 +419,37 @@ class NotificationToast(QFrame):
"""
)
# icon glyph vs badge background: darker badge, lighter icon in light mode
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
icon = material_icon(
icon_name=SEVERITY[self._kind.value]["icon"],
color=icon_fg,
filled=True,
size=(24, 24),
convert_to_pixmap=False,
)
self._icon_btn.setIcon(icon)
badge_bg = QtGui.QColor(self._accent_color)
if theme == "light":
# darken and strengthen the badge on light cards for contrast
badge_bg = badge_bg.darker(115)
badge_bg.setAlphaF(0.70)
else:
badge_bg.setAlphaF(0.30)
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
self._icon_btn.setStyleSheet(
f"""
QToolButton {{
background: {icon_bg};
border: none;
border-radius: 20px;
}}
"""
)
# stronger accent wash in light mode, slightly stronger in dark too
self._accent_alpha = 110 if theme == "light" else 60
self.update()
########################################
@@ -488,7 +531,9 @@ class NotificationToast(QFrame):
# accent gradient, fades to transparent
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
accent = QtGui.QColor(self._accent_color)
accent.setAlpha(50)
if getattr(self, "_theme", "dark") == "light":
accent = accent.darker(115)
accent.setAlpha(getattr(self, "_accent_alpha", 50))
grad.setColorAt(0.0, accent)
fade = QtGui.QColor(self._accent_color)
fade.setAlpha(0)
@@ -690,7 +735,6 @@ class NotificationCentre(QScrollArea):
toast.notification_id = notification_id
broker = BECNotificationBroker()
toast.closed.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
toast.expired.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
toast.closed.connect(lambda: self._hide_notification(toast))
toast.expired.connect(lambda t=toast: self._handle_expire(t))
toast.expanded.connect(self._adjust_height)
@@ -1016,32 +1060,55 @@ class BECNotificationBroker(BECConnector, QObject):
"""
Called when a new alarm arrives. Builds and pushes a toast to each centre
with a shared notification_id, and hooks its close/expire signals.
Args:
msg(dict): The message containing alarm details.
meta(dict): Metadata about the alarm.
"""
msg = msg or {}
meta = meta or {}
centres = WidgetIO.find_widgets(NotificationCentre)
kind = self._banner_kind_from_severity(msg.get("severity", 0))
# Normalise the incoming info payload (can be ErrorInfo, dict or missing entirely)
raw_info = msg.get("info")
if isinstance(raw_info, dict):
try:
raw_info = ErrorInfo(**raw_info)
except Exception:
raw_info = None
notification_id = getattr(raw_info, "id", None) or uuid4().hex
# build title and body
scan_id = meta.get("scan_id")
scan_number = meta.get("scan_number")
formatted_trace = self._err_util.format_traceback(msg.get("msg", ""))
short_msg = self._err_util.parse_error_message(formatted_trace)
title = msg.get("alarm_type", "Alarm")
alarm_type = msg.get("alarm_type") or getattr(raw_info, "exception_type", None) or "Alarm"
title = alarm_type
if scan_number:
title += f" - Scan #{scan_number}"
body_text = short_msg
# build detailed traceback
sections: list[str] = []
if scan_id:
sections.extend(["-------- SCAN_ID --------\n", scan_id])
sections.extend(["-------- TRACEBACK --------", formatted_trace])
source = msg.get("source")
if source:
source_pretty = json.dumps(source, indent=4, default=str)
sections.extend(["", "-------- SOURCE --------", source_pretty])
detailed_trace = "\n".join(sections)
trace_text = getattr(raw_info, "error_message", None) or msg.get("msg") or ""
compact_msg = getattr(raw_info, "compact_error_message", None)
# Prefer the compact message; fall back to parsing the traceback for a humanreadable snippet
body_text = compact_msg or self._err_util.parse_error_message(trace_text)
# build detailed traceback for the expandable panel
detailed_trace: str | None = None
if trace_text:
sections: list[str] = []
if scan_id:
sections.extend(["-------- SCAN_ID --------\n", scan_id])
sections.extend(["-------- TRACEBACK --------", trace_text])
detailed_trace = "\n".join(sections)
lifetime = 0 if kind == SeverityKind.MAJOR else 5_000
# generate one ID for all toasts of this event
notification_id = uuid4().hex
if notification_id in self._active_notifications:
return # already posted
# record this notification for future centres
self._active_notifications[notification_id] = {
"title": title,
@@ -1059,9 +1126,8 @@ class BECNotificationBroker(BECConnector, QObject):
lifetime_ms=lifetime,
notification_id=notification_id,
)
# broadcast any close or expire
# broadcast close events (expiry is handled locally to keep history)
toast.closed.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
toast.expired.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
@SafeSlot(dict, dict)
def on_scan_status(self, msg: dict, meta: dict) -> None:
@@ -1086,6 +1152,13 @@ class BECNotificationBroker(BECConnector, QObject):
Translate an integer severity (0/1/2) into a SeverityKind enum.
Unknown values fall back to SeverityKind.WARNING.
"""
if isinstance(severity, SeverityKind):
return severity
if isinstance(severity, str):
try:
return SeverityKind(severity)
except ValueError:
pass
try:
return SeverityKind[Alarms(severity).name] # e.g. WARNING → SeverityKind.WARNING
except (ValueError, KeyError):
@@ -1164,10 +1237,10 @@ class DemoWindow(QMainWindow): # pragma: no cover
# ----- wiring ------------------------------------------------------------
self._counter = 1
self.info_btn.clicked.connect(lambda: self._post("info"))
self.warning_btn.clicked.connect(lambda: self._post("warning"))
self.minor_btn.clicked.connect(lambda: self._post("minor"))
self.major_btn.clicked.connect(lambda: self._post("major"))
self.info_btn.clicked.connect(lambda: self._post(SeverityKind.INFO))
self.warning_btn.clicked.connect(lambda: self._post(SeverityKind.WARNING))
self.minor_btn.clicked.connect(lambda: self._post(SeverityKind.MINOR))
self.major_btn.clicked.connect(lambda: self._post(SeverityKind.MAJOR))
# Raise buttons simulate alarms
self.raise_warning_btn.clicked.connect(lambda: self._raise_error(Alarms.WARNING))
self.raise_minor_btn.clicked.connect(lambda: self._raise_error(Alarms.MINOR))
@@ -1183,30 +1256,28 @@ class DemoWindow(QMainWindow): # pragma: no cover
indicator.hide_all_requested.connect(self.notification_centre.hide_all)
# ------------------------------------------------------------------
def _post(self, kind):
expire = 0 if kind == "error" else 5000
trace = (
'Traceback (most recent call last):\n File "<stdin>", line 1\nZeroDivisionError: 1/0'
if kind == "error"
else None
)
self.notification_centre.add_notification(
title=f"{kind.capitalize()} #{self._counter}",
body="Lorem ipsum dolor sit amet.",
kind=SeverityKind(kind),
lifetime_ms=expire,
traceback=trace,
)
def _post(self, kind: SeverityKind):
"""
Send a simple notification through the broker (non-error case).
"""
msg = {
"severity": kind.value, # handled by broker for SeverityKind
"alarm_type": f"{kind.value.capitalize()}",
"msg": f"{kind.value.capitalize()} #{self._counter}",
}
self.notification_broker.post_notification(msg, meta={})
self._counter += 1
def _raise_error(self, severity):
"""Simulate an error that would be caught by the notification broker."""
self.notification_broker.client.connector.raise_alarm(
severity=severity,
alarm_type="ValueError",
source={"device": "samx", "source": "async_file_writer"},
msg=f"test alarm",
metadata={"test": 1},
info=ErrorInfo(
id=uuid4().hex,
exception_type="ValueError",
error_message="An example error occurred in DemoWindowApp.",
compact_error_message="An example error occurred.",
),
)
# this part is same as implemented in the BECMainWindow
@@ -1225,6 +1296,7 @@ class DemoWindow(QMainWindow): # pragma: no cover
def main(): # pragma: no cover
app = QtWidgets.QApplication(sys.argv)
apply_theme("dark")
win = DemoWindow()
win.show()
sys.exit(app.exec())

View File

@@ -12,4 +12,4 @@ class BECWebLinksMixin:
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
SCAN_PROGRESS_HEIGHT = 12 # px
def __init__(
self,
@@ -201,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -219,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
@@ -474,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup

View File

@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover

View File

@@ -26,6 +26,7 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSettings
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger
@@ -83,39 +84,7 @@ class Heatmap(ImageBase):
"""
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
*PlotBase.USER_ACCESS,
# ImageView Specific Settings
"color_map",
"color_map.setter",
@@ -125,8 +94,6 @@ class Heatmap(ImageBase):
"v_min.setter",
"v_max",
"v_max.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"autorange",
"autorange.setter",
"autorange_mode",
@@ -301,6 +268,20 @@ class Heatmap(ImageBase):
if show_config_label is None:
show_config_label = self._image_config.show_config_label
def _device_key(device: HeatmapDeviceSignal | None) -> tuple[str | None, str | None]:
return (device.name if device else None, device.entry if device else None)
prev_cfg = getattr(self, "_image_config", None)
config_changed = False
if prev_cfg and prev_cfg.x_device and prev_cfg.y_device and prev_cfg.z_device:
config_changed = any(
(
_device_key(prev_cfg.x_device) != (x_name, x_entry),
_device_key(prev_cfg.y_device) != (y_name, y_entry),
_device_key(prev_cfg.z_device) != (z_name, z_entry),
)
)
self._image_config = HeatmapConfig(
parent_id=self.gui_id,
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
@@ -315,7 +296,10 @@ class Heatmap(ImageBase):
show_config_label=show_config_label,
)
self.color_map = color_map
self.reload = reload
self.reload = reload or config_changed
if config_changed:
self._grid_index = None
self.main_image.clear()
self.update_labels()
self._fetch_running_scan()
@@ -636,55 +620,51 @@ class Heatmap(ImageBase):
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
shape = (
args[self._image_config.x_device.entry][-1],
args[self._image_config.y_device.entry][-1],
)
x_entry = self._image_config.x_device.entry
y_entry = self._image_config.y_device.entry
shape = (args[x_entry][-1], args[y_entry][-1])
data = self.main_image.raw_data
if data is None or data.shape != shape:
data = np.empty(shape)
data.fill(np.nan)
def _get_grid_data(axis, snaked=True):
x_grid, y_grid = np.meshgrid(axis[0], axis[1])
if snaked:
y_grid.T[::2] = np.fliplr(y_grid.T[::2])
x_flat = x_grid.T.ravel()
y_flat = y_grid.T.ravel()
positions = np.vstack((x_flat, y_flat)).T
return positions
elif self.reload:
data.fill(np.nan)
snaked = msg.request_inputs["kwargs"].get("snaked", True)
# If the scan's fast axis is x, we need to swap the x and y axes
swap = bool(msg.request_inputs["arg_bundle"][4] == self._image_config.x_device.entry)
slow_entry, fast_entry = (
msg.request_inputs["arg_bundle"][0],
msg.request_inputs["arg_bundle"][4],
)
# calculate the QTransform to put (0,0) at the axis origin
scan_pos = np.asarray(msg.info["positions"])
x_min = min(scan_pos[:, 0])
x_max = max(scan_pos[:, 0])
y_min = min(scan_pos[:, 1])
y_max = max(scan_pos[:, 1])
scan_pos = np.asarray(msg.info["positions"], dtype=float)
relative = bool(msg.request_inputs["kwargs"].get("relative", False))
x_range = x_max - x_min
y_range = y_max - y_min
def _axis_column(entry: str) -> int:
return 0 if entry == slow_entry else 1
pixel_size_x = x_range / (shape[0] - 1)
pixel_size_y = y_range / (shape[1] - 1)
def _axis_levels(entry: str, npts: int) -> np.ndarray:
start, stop = args[entry][:2]
if relative:
origin = float(scan_pos[0, _axis_column(entry)] - start)
return origin + np.linspace(start, stop, npts)
return np.linspace(start, stop, npts)
x_levels = _axis_levels(x_entry, shape[0])
y_levels = _axis_levels(y_entry, shape[1])
pixel_size_x = (
float(x_levels[-1] - x_levels[0]) / max(shape[0] - 1, 1) if shape[0] > 1 else 1.0
)
pixel_size_y = (
float(y_levels[-1] - y_levels[0]) / max(shape[1] - 1, 1) if shape[1] > 1 else 1.0
)
transform = QTransform()
if swap:
transform.scale(pixel_size_y, pixel_size_x)
transform.translate(y_min / pixel_size_y - 0.5, x_min / pixel_size_x - 0.5)
else:
transform.scale(pixel_size_x, pixel_size_y)
transform.translate(x_min / pixel_size_x - 0.5, y_min / pixel_size_y - 0.5)
target_positions = _get_grid_data(
(np.arange(shape[int(swap)]), np.arange(shape[int(not swap)])), snaked=snaked
)
transform.scale(pixel_size_x, pixel_size_y)
transform.translate(x_levels[0] / pixel_size_x - 0.5, y_levels[0] / pixel_size_y - 0.5)
# Fill the data array with the z values
if self._grid_index is None or self.reload:
@@ -692,7 +672,16 @@ class Heatmap(ImageBase):
self.reload = False
for i in range(self._grid_index, len(z_data)):
data[target_positions[i, int(swap)], target_positions[i, int(not swap)]] = z_data[i]
slow_i, fast_i = divmod(i, args[fast_entry][-1])
if snaked and (slow_i % 2 == 1):
fast_i = args[fast_entry][-1] - 1 - fast_i
if x_entry == fast_entry:
x_i, y_i = fast_i, slow_i
else:
x_i, y_i = slow_i, fast_i
data[x_i, y_i] = z_data[i]
self._grid_index = len(z_data)
return data, transform

View File

@@ -22,6 +22,7 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger
@@ -59,39 +60,7 @@ class Image(ImageBase):
RPC = True
ICON_NAME = "image"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
*PlotBase.USER_ACCESS,
# ImageView Specific Settings
"color_map",
"color_map.setter",
@@ -101,8 +70,6 @@ class Image(ImageBase):
"v_min.setter",
"v_max",
"v_max.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"autorange",
"autorange.setter",
"autorange_mode",

View File

@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
from qtpy.QtCore import QPointF, Signal, SignalInstance
from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.utils import Colors
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
@@ -131,8 +132,7 @@ class ImageLayerManager:
image.setZValue(z_position)
image.removed.connect(self._remove_destroyed_layer)
# FIXME: For now, we hard-code the default color map here. In the future, this should be configurable.
image.color_map = "plasma"
image.color_map = self.parent.config.color_map
self.layers[name] = ImageLayer(name=name, image=image, sync=sync)
self.plot_item.addItem(image)
@@ -249,6 +249,8 @@ class ImageBase(PlotBase):
Base class for the Image widget.
"""
MAX_TICKS_COLORBAR = 10
sync_colorbar_with_autorange = Signal()
image_updated = Signal()
layer_added = Signal(str)
@@ -460,18 +462,20 @@ class ImageBase(PlotBase):
self.setProperty("autorange", False)
if style == "simple":
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
cmap = Colors.get_colormap(self.config.color_map)
self._color_bar = pg.ColorBarItem(colorMap=cmap)
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
self.config.color_bar = "simple"
elif style == "full":
self._color_bar = pg.HistogramLUTItem()
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar.gradient.loadPreset(self.config.color_map)
self.config.color_bar = "full"
self._apply_colormap_to_colorbar(self.config.color_map)
self._color_bar.sigLevelsChanged.connect(disable_autorange)
self.plot_widget.addItem(self._color_bar, row=0, col=1)
self.config.color_bar = style
else:
if self._color_bar:
self.plot_widget.removeItem(self._color_bar)
@@ -484,6 +488,37 @@ class ImageBase(PlotBase):
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
def _apply_colormap_to_colorbar(self, color_map: str) -> None:
if not self._color_bar:
return
cmap = Colors.get_colormap(color_map)
if self.config.color_bar == "simple":
self._color_bar.setColorMap(cmap)
return
if self.config.color_bar != "full":
return
gradient = getattr(self._color_bar, "gradient", None)
if gradient is None:
return
positions = np.linspace(0.0, 1.0, self.MAX_TICKS_COLORBAR)
colors = cmap.map(positions, mode="byte")
colors = np.asarray(colors)
if colors.ndim != 2:
return
if colors.shape[1] == 3: # add alpha
alpha = np.full((colors.shape[0], 1), 255, dtype=colors.dtype)
colors = np.concatenate([colors, alpha], axis=1)
ticks = [(float(p), tuple(int(x) for x in c)) for p, c in zip(positions, colors)]
state = {"mode": "rgb", "ticks": ticks}
gradient.restoreState(state)
################################################################################
# Static rois with roi manager
@@ -754,10 +789,7 @@ class ImageBase(PlotBase):
layer.image.color_map = value
if self._color_bar:
if self.config.color_bar == "simple":
self._color_bar.setColorMap(value)
elif self.config.color_bar == "full":
self._color_bar.gradient.loadPreset(value)
self._apply_colormap_to_colorbar(self.config.color_map)
except ValidationError:
return

View File

@@ -119,7 +119,8 @@ class ImageItem(BECConnector, pg.ImageItem):
"""Set a new color map."""
try:
self.config.color_map = value
self.setColorMap(value)
cmap = Colors.get_colormap(self.config.color_map)
self.setColorMap(cmap)
except ValidationError:
logger.error(f"Invalid colormap '{value}' provided.")

View File

@@ -90,45 +90,7 @@ class MotorMap(PlotBase):
RPC = True
ICON_NAME = "my_location"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"screenshot",
*PlotBase.USER_ACCESS,
# motor_map specific
"color",
"color.setter",

View File

@@ -56,47 +56,7 @@ class MultiWaveform(PlotBase):
RPC = True
ICON_NAME = "ssid_chart"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
*PlotBase.USER_ACCESS,
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",

View File

@@ -63,6 +63,50 @@ class UIMode(Enum):
class PlotBase(BECWidget, QWidget):
PLUGIN = False
RPC = False
BASE_USER_ACCESS = [
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
]
USER_ACCESS = [*BECWidget.USER_ACCESS, *BASE_USER_ACCESS]
# Custom Signals
property_changed = Signal(str, object)
@@ -831,6 +875,40 @@ class PlotBase(BECWidget, QWidget):
self._apply_y_label()
self.property_changed.emit("inner_axes", value)
@SafeProperty(bool, doc="Invert X axis.")
def invert_x(self) -> bool:
"""
Invert X axis.
"""
return self.plot_item.vb.state.get("xInverted", False)
@invert_x.setter
def invert_x(self, value: bool):
"""
Invert X axis.
Args:
value(bool): The value to set.
"""
self.plot_item.vb.invertX(value)
@SafeProperty(bool, doc="Invert Y axis.")
def invert_y(self) -> bool:
"""
Invert Y axis.
"""
return self.plot_item.vb.state.get("yInverted", False)
@invert_y.setter
def invert_y(self, value: bool):
"""
Invert Y axis.
Args:
value(bool): The value to set.
"""
self.plot_item.vb.invertY(value)
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
def lock_aspect_ratio(self) -> bool:
"""

View File

@@ -44,47 +44,7 @@ class ScatterWaveform(PlotBase):
RPC = True
ICON_NAME = "scatter_plot"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
*PlotBase.USER_ACCESS,
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",

View File

@@ -24,6 +24,7 @@ class DeviceSignal(BaseModel):
entry: str
dap: str | None = None
dap_oversample: int = 1
dap_parameters: dict | None = None
model_config: dict = {"validate_assignment": True}
@@ -206,6 +207,7 @@ class Curve(BECConnector, pg.PlotDataItem):
"""
if self.config.source in ["custom", "history"]:
self.setData(x, y)
self.parent_item.request_dap_update.emit()
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")

View File

@@ -1,13 +1,13 @@
from __future__ import annotations
import json
from typing import Literal
from typing import TYPE_CHECKING, Literal
import lmfit
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal
@@ -41,6 +41,15 @@ from bec_widgets.widgets.services.scan_history_browser.scan_history_browser impo
)
logger = bec_logger.logger
_DAP_PARAM = object()
if TYPE_CHECKING: # pragma: no cover
import lmfit # type: ignore
else:
try:
import lmfit # type: ignore
except Exception: # pragma: no cover
lmfit = None
# noinspection PyDataclass
@@ -67,49 +76,8 @@ class Waveform(PlotBase):
RPC = True
ICON_NAME = "show_chart"
USER_ACCESS = [
# General PlotBase Settings
*PlotBase.USER_ACCESS,
"_config_dict",
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"auto_range",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# Waveform Specific RPC Access
"curves",
"x_mode",
@@ -738,6 +706,7 @@ class Waveform(PlotBase):
color: str | None = None,
label: str | None = None,
dap: str | None = None,
dap_parameters: dict | lmfit.Parameters | None | object = None,
scan_id: str | None = None,
scan_number: int | None = None,
**kwargs,
@@ -759,9 +728,11 @@ class Waveform(PlotBase):
y_entry(str): The name of the entry for the y-axis.
color(str): The color of the curve.
label(str): The label of the curve.
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
dap(str): The dap model to use for the curve. When provided, a DAP curve is
attached automatically for device, history, or custom data sources. Use
the same string as the LMFit model name.
dap_parameters(dict | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
Values can be numeric (interpreted as fixed parameters) or dicts like`{"value": 1.0, "vary": False}`.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
@@ -774,6 +745,8 @@ class Waveform(PlotBase):
source = "custom"
x_data = None
y_data = None
if dap_parameters is _DAP_PARAM:
dap_parameters = kwargs.pop("dap_parameters", None) or kwargs.pop("parameters", None)
# 1. Custom curve logic
if x is not None and y is not None:
@@ -850,8 +823,10 @@ class Waveform(PlotBase):
# CREATE THE CURVE
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
if dap is not None and source == "device":
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
if dap is not None and curve.config.source in ("device", "history", "custom"):
self.add_dap_curve(
device_label=curve.name(), dap_name=dap, dap_parameters=dap_parameters, **kwargs
)
return curve
@@ -864,34 +839,42 @@ class Waveform(PlotBase):
dap_name: str,
color: str | None = None,
dap_oversample: int = 1,
dap_parameters: dict | lmfit.Parameters | None = None,
**kwargs,
) -> Curve:
"""
Create a new DAP curve referencing the existing device curve `device_label`,
with the data processing model `dap_name`.
Create a new DAP curve referencing the existing curve `device_label`, with the
data processing model `dap_name`. DAP curves can be attached to curves that
originate from live devices, history, or fully custom data sources.
Args:
device_label(str): The label of the device curve to add DAP to.
device_label(str): The label of the source curve to add DAP to.
dap_name(str): The name of the DAP model to use.
color(str): The color of the curve.
dap_oversample(int): The oversampling factor for the DAP curve.
dap_parameters(dict | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
**kwargs
Returns:
Curve: The new DAP curve.
"""
# 1) Find the existing device curve by label
# 1) Find the existing curve by label
device_curve = self._find_curve_by_label(device_label)
if not device_curve:
raise ValueError(f"No existing curve found with label '{device_label}'.")
if device_curve.config.source not in ("device", "history"):
if device_curve.config.source not in ("device", "history", "custom"):
raise ValueError(
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
f"Curve '{device_label}' is not compatible with DAP. "
f"Only device, history, or custom curves support fitting."
)
dev_name = device_curve.config.signal.name
dev_entry = device_curve.config.signal.entry
dev_name = getattr(getattr(device_curve.config, "signal", None), "name", None)
dev_entry = getattr(getattr(device_curve.config, "signal", None), "entry", None)
if dev_name is None:
dev_name = device_label
if dev_entry is None:
dev_entry = "custom"
# 2) Build a label for the new DAP curve
dap_label = f"{device_label}-{dap_name}"
@@ -917,7 +900,11 @@ class Waveform(PlotBase):
# Attach device signal with DAP
config.signal = DeviceSignal(
name=dev_name, entry=dev_entry, dap=dap_name, dap_oversample=dap_oversample
name=dev_name,
entry=dev_entry,
dap=dap_name,
dap_oversample=dap_oversample,
dap_parameters=self._normalize_dap_parameters(dap_parameters),
)
# 4) Create the DAP curve config using `_add_curve(...)`
@@ -1555,7 +1542,7 @@ class Waveform(PlotBase):
self.request_dap_update.emit()
def _check_async_signal_found(self, name: str, signal: str) -> bool:
def _check_async_signal_found(self, name: str, signal: str) -> tuple[bool, str]:
"""
Check if the async signal is found in the BEC device manager.
@@ -1564,13 +1551,16 @@ class Waveform(PlotBase):
signal(str): The entry of the async signal.
Returns:
bool: True if the async signal is found, False otherwise.
tuple[bool, str]: A tuple where the first element is True if the async signal is found (False otherwise),
and the second element is the signal name (either the original signal or the storage_name for AsyncMultiSignal).
"""
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
bec_async_signals = self.client.device_manager.get_bec_signals(
["AsyncSignal", "AsyncMultiSignal"]
)
for entry_name, _, entry_data in bec_async_signals:
if entry_name == name and entry_data.get("obj_name") == signal:
return True
return False
return True, entry_data.get("storage_name")
return False, signal
def _setup_async_curve(self, curve: Curve):
"""
@@ -1581,7 +1571,7 @@ class Waveform(PlotBase):
"""
name = curve.config.signal.name
signal = curve.config.signal.entry
async_signal_found = self._check_async_signal_found(name, signal)
async_signal_found, signal = self._check_async_signal_found(name, signal)
try:
curve.clear_data()
@@ -1663,6 +1653,9 @@ class Waveform(PlotBase):
continue
# Ensure we have numpy array for data_plot_y
data_plot_y = np.asarray(data_plot_y)
if data_plot_y.ndim == 0:
# Convert scalars/0d arrays to 1d so len() and stacking work
data_plot_y = data_plot_y.reshape(1)
# Add
if instruction == "add":
if len(max_shape) > 1:
@@ -1791,12 +1784,21 @@ class Waveform(PlotBase):
x_min = None
x_max = None
dap_parameters = getattr(dap_curve.config.signal, "dap_parameters", None)
dap_kwargs = {
"data_x": x_data,
"data_y": y_data,
"oversample": dap_curve.dap_oversample,
}
if dap_parameters:
dap_kwargs["parameters"] = dap_parameters
msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D",
dap_type="on_demand",
config={
"args": [],
"kwargs": {"data_x": x_data, "data_y": y_data},
"kwargs": dap_kwargs,
"class_args": model._plugin_info["class_args"],
"class_kwargs": model._plugin_info["class_kwargs"],
"curve_label": dap_curve.name(),
@@ -1805,6 +1807,49 @@ class Waveform(PlotBase):
)
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
@staticmethod
def _normalize_dap_parameters(parameters: dict | lmfit.Parameters | None) -> dict | None:
"""
Normalize user-provided lmfit parameters into a JSON-serializable dict suitable for the DAP server.
Supports:
- `lmfit.Parameters`
- `dict[name -> number]` (treated as fixed parameter with `vary=False`)
- `dict[name -> dict]` (lmfit.Parameter fields; defaults to `vary=False` if unspecified)
- `dict[name -> lmfit.Parameter]`
"""
if parameters is None:
return None
if lmfit is not None and isinstance(parameters, lmfit.Parameters):
return serialize_lmfit_params(parameters)
if not isinstance(parameters, dict):
if lmfit is None:
raise TypeError(
"dap_parameters must be a dict when lmfit is not installed on the client."
)
raise TypeError("dap_parameters must be a dict or lmfit.Parameters (or omitted).")
normalized: dict[str, dict] = {}
for name, spec in parameters.items():
if spec is None:
continue
if isinstance(spec, (int, float, np.number)):
normalized[name] = {"name": name, "value": float(spec), "vary": False}
continue
if lmfit is not None and isinstance(spec, lmfit.Parameter):
normalized[name] = serialize_param_object(spec)
continue
if isinstance(spec, dict):
normalized[name] = {"name": name, **spec}
if "vary" not in normalized[name]:
normalized[name]["vary"] = False
continue
raise TypeError(
f"Invalid dap_parameters entry for '{name}': expected number, dict, or lmfit.Parameter."
)
return normalized or None
@SafeSlot(dict, dict)
def update_dap_curves(self, msg, metadata):
"""
@@ -1822,14 +1867,6 @@ class Waveform(PlotBase):
if not curve:
return
# Get data from the parent (device) curve
parent_curve = self._find_curve_by_label(curve.config.parent_label)
if parent_curve is None:
return
x_parent, _ = parent_curve.get_data()
if x_parent is None or len(x_parent) == 0:
return
# Retrieve and store the fit parameters and summary from the DAP server response
try:
curve.dap_params = msg["data"][1]["fit_parameters"]
@@ -1838,19 +1875,13 @@ class Waveform(PlotBase):
logger.warning(f"Failed to retrieve DAP data for curve '{curve.name()}'")
return
# Render model according to the DAP model name and parameters
model_name = curve.config.signal.dap
model_function = getattr(lmfit.models, model_name)()
x_min, x_max = x_parent.min(), x_parent.max()
oversample = curve.dap_oversample
new_x = np.linspace(x_min, x_max, int(len(x_parent) * oversample))
# Evaluate the model with the provided parameters to generate the y values
new_y = model_function.eval(**curve.dap_params, x=new_x)
# Update the curve with the new data
curve.setData(new_x, new_y)
# Plot the fitted curve using the server-provided output to avoid requiring lmfit on the client.
try:
fit_data = msg["data"][0]
curve.setData(np.asarray(fit_data["x"]), np.asarray(fit_data["y"]))
except Exception:
logger.exception(f"Failed to plot DAP result for curve '{curve.name()}'")
return
metadata.update({"curve_id": curve_id})
self.dap_params_update.emit(curve.dap_params, metadata)
@@ -2370,7 +2401,7 @@ class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Waveform Demo")
self.resize(800, 600)
self.resize(1200, 600)
self.main_widget = QWidget(self)
self.layout = QHBoxLayout(self.main_widget)
self.setCentralWidget(self.main_widget)
@@ -2382,8 +2413,71 @@ class DemoApp(QMainWindow): # pragma: no cover
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.waveform_side.plot(y_name="bpm3a", y_entry="bpm3a")
self.custom_waveform = Waveform(popups=True)
self._populate_custom_curve_demo()
self.layout.addWidget(self.waveform_side)
self.layout.addWidget(self.waveform_popup)
self.layout.addWidget(self.custom_waveform)
def _populate_custom_curve_demo(self):
"""
Showcase how to attach a DAP fit to a fully custom curve.
The example generates a noisy Gaussian trace, plots it as custom data, and
immediately adds a Gaussian model fit. When the widget is plugged into a
running BEC instance, the fit curve will be requested like any other device
signal. This keeps the example minimal while demonstrating the new workflow.
"""
x = np.linspace(-4, 4, 600)
rng = np.random.default_rng(42)
noise = rng.normal(loc=0, scale=0.05, size=x.size)
amplitude = 3.5
center = 0.5
sigma = 0.8
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
# 1) No explicit parameters: server will use lmfit defaults/guesses.
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
# 2) Easy dict: numbers mean "fix this parameter to value" (vary=False).
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-fixed-easy",
dap="GaussianModel",
dap_parameters={"amplitude": 1.0},
dap_oversample=5,
)
# 3) lmfit-style dict: any subset of lmfit.Parameter fields.
# Here `center` is not fixed (vary=True) but its initial value is set.
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-override-dict",
dap="GaussianModel",
dap_parameters={
"center": {"value": 1.2, "vary": True},
"sigma": {"value": sigma, "vary": False, "min": 0.0},
},
)
# 4) Passing a real `lmfit.Parameters` object (optional: requires lmfit on the client).
if lmfit is not None:
params_gauss = lmfit.models.GaussianModel().make_params()
params_gauss["amplitude"].set(value=amplitude, vary=False)
params_gauss["center"].set(value=center, vary=False)
params_gauss["sigma"].set(value=sigma, vary=False, min=0.0)
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-fixed-params",
dap="GaussianModel",
dap_parameters=params_gauss,
)
else:
logger.info("Skipping lmfit.Parameters demo (lmfit not installed on client).")
if __name__ == "__main__": # pragma: no cover

View File

@@ -55,7 +55,9 @@ class DeviceBrowser(BECWidget, QWidget):
) -> None:
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
self._config_helper = ConfigHelper(
self.client.connector, self.client._service_name, self.client.device_manager
)
self._q_threadpool = QThreadPool()
self.ui = None
self.init_ui()

View File

@@ -5,7 +5,7 @@ from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from pydantic import ValidationError, field_validator
from pydantic import field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from qtpy.QtWidgets import (
QApplication,
@@ -65,7 +65,7 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._initial_config = {}
super().__init__(parent=parent, **kwargs)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
self.client.connector, self.client._service_name, self.client.device_manager
)
self._device = device
self._action: Literal["update", "add"] = action

View File

@@ -0,0 +1,574 @@
import os
from typing import Optional
from qtpy.QtCore import QMargins, Qt, Signal
from qtpy.QtGui import QIntValidator
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
class PdfViewerWidget(BECWidget, QWidget):
"""A widget to display PDF documents with toolbar controls."""
# Emitted when a PDF document is successfully loaded, providing the file path.
document_ready = Signal(str)
PLUGIN = True
RPC = True
ICON_NAME = "picture_as_pdf"
USER_ACCESS = [
"load_pdf",
"zoom_in",
"zoom_out",
"fit_to_width",
"fit_to_page",
"reset_zoom",
"previous_page",
"next_page",
"toggle_continuous_scroll",
"page_spacing",
"page_spacing.setter",
"side_margins",
"side_margins.setter",
"go_to_first_page",
"go_to_last_page",
"jump_to_page",
"current_page",
"current_file_path",
"current_file_path.setter",
]
def __init__(
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
# Set up the layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create the PDF document and view first
self._pdf_document = QPdfDocument(self)
self.pdf_view = QPdfView()
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
# Create toolbar after PDF components are initialized
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._setup_toolbar()
# Add widgets to layout
layout.addWidget(self.toolbar)
layout.addWidget(self.pdf_view)
# Current file path and spacing settings
self._current_file_path = None
self._page_spacing = 5 # Default spacing between pages in continuous mode
self._side_margins = 10 # Default side margins (horizontal spacing)
def _setup_toolbar(self):
"""Set up the toolbar with PDF control buttons."""
# Create separate bundles for different control groups
file_bundle = self.toolbar.new_bundle("file_controls")
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
view_bundle = self.toolbar.new_bundle("view_controls")
nav_bundle = self.toolbar.new_bundle("navigation_controls")
# File operations
open_action = MaterialIconAction(
icon_name="folder_open", tooltip="Open PDF File", parent=self
)
open_action.action.triggered.connect(self.open_file_dialog)
self.toolbar.components.add("open_file", open_action)
file_bundle.add_action("open_file")
# Zoom controls
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
zoom_in_action.action.triggered.connect(self.zoom_in)
self.toolbar.components.add("zoom_in", zoom_in_action)
zoom_bundle.add_action("zoom_in")
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
zoom_out_action.action.triggered.connect(self.zoom_out)
self.toolbar.components.add("zoom_out", zoom_out_action)
zoom_bundle.add_action("zoom_out")
fit_width_action = MaterialIconAction(
icon_name="fit_screen", tooltip="Fit to Width", parent=self
)
fit_width_action.action.triggered.connect(self.fit_to_width)
self.toolbar.components.add("fit_width", fit_width_action)
zoom_bundle.add_action("fit_width")
fit_page_action = MaterialIconAction(
icon_name="fullscreen", tooltip="Fit to Page", parent=self
)
fit_page_action.action.triggered.connect(self.fit_to_page)
self.toolbar.components.add("fit_page", fit_page_action)
zoom_bundle.add_action("fit_page")
reset_zoom_action = MaterialIconAction(
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
)
reset_zoom_action.action.triggered.connect(self.reset_zoom)
self.toolbar.components.add("reset_zoom", reset_zoom_action)
zoom_bundle.add_action("reset_zoom")
# View controls
continuous_scroll_action = MaterialIconAction(
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
)
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
view_bundle.add_action("continuous_scroll")
# Navigation controls
prev_page_action = MaterialIconAction(
icon_name="navigate_before", tooltip="Previous Page", parent=self
)
prev_page_action.action.triggered.connect(self.previous_page)
self.toolbar.components.add("prev_page", prev_page_action)
nav_bundle.add_action("prev_page")
next_page_action = MaterialIconAction(
icon_name="navigate_next", tooltip="Next Page", parent=self
)
next_page_action.action.triggered.connect(self.next_page)
self.toolbar.components.add("next_page", next_page_action)
nav_bundle.add_action("next_page")
# Page jump widget (in navigation bundle)
self._setup_page_jump_widget(nav_bundle)
# Show all bundles
self.toolbar.show_bundles(
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
)
# Initialize navigation button tooltips for single page mode (default)
self._update_navigation_buttons_for_mode(continuous=False)
# Initialize navigation button states
self._update_navigation_button_states()
def _setup_page_jump_widget(self, nav_bundle):
"""Set up the page jump widget (label + line edit)."""
# Create a container widget for the page jump controls
page_jump_container = QWidget()
page_jump_layout = QHBoxLayout(page_jump_container)
page_jump_layout.setContentsMargins(5, 0, 5, 0)
page_jump_layout.setSpacing(3)
# Page input field
self.page_input = QLineEdit()
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1100000
self.page_input.setFixedWidth(50)
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.page_input.setPlaceholderText("1")
self.page_input.setToolTip("Enter page number and press Enter")
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
# Total pages label
self.total_pages_label = QLabel("/ 1")
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
# Add widgets to layout
page_jump_layout.addWidget(self.page_input)
page_jump_layout.addWidget(self.total_pages_label)
# Create a WidgetAction for the page jump controls
# No manual separator needed - bundles are automatically separated
page_jump_action = WidgetAction(
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
)
self.toolbar.components.add("page_jump", page_jump_action)
nav_bundle.add_action("page_jump")
def _line_edit_jump_to_page(self):
"""Jump to the page entered in the line edit."""
page_text = self.page_input.text().strip()
if not page_text:
return
# We validated input to be integer, so safe to convert directly
self.jump_to_page(int(page_text))
def _update_navigation_button_states(self):
"""Update the enabled/disabled state of navigation buttons."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
# No document loaded - disable all navigation
self._set_navigation_enabled(False, False)
self._update_page_display(1, 1)
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
total_pages = self._pdf_document.pageCount()
# Update button states
prev_enabled = current_page > 0
next_enabled = current_page < (total_pages - 1)
self._set_navigation_enabled(prev_enabled, next_enabled)
# Update page display
self._update_page_display(current_page + 1, total_pages)
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
"""Set the enabled state of navigation buttons."""
prev_action = self.toolbar.components.get_action("prev_page")
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setEnabled(prev_enabled)
next_action = self.toolbar.components.get_action("next_page")
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setEnabled(next_enabled)
def _update_page_display(self, current_page: int, total_pages: int):
"""Update the page display in the toolbar."""
if hasattr(self, "page_input"):
self.page_input.setText(str(current_page))
self.page_input.setPlaceholderText(str(current_page))
if hasattr(self, "total_pages_label"):
self.total_pages_label.setText(f"/ {total_pages}")
@SafeProperty(str)
def current_file_path(self):
"""Get the current PDF file path."""
return self._current_file_path
@current_file_path.setter
def current_file_path(self, value: str):
"""
Set the current PDF file path and load the document.
Args:
value (str): Path to the PDF file to load.
"""
if not isinstance(value, str):
raise ValueError("current_file_path must be a string")
self.load_pdf(value)
@SafeProperty(int)
def page_spacing(self):
"""Get the spacing between pages in continuous scroll mode."""
return self._page_spacing
@property
def current_page(self):
"""Get the current page number (1-based index)."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return 0
navigator = self.pdf_view.pageNavigator()
return navigator.currentPage() + 1
@page_spacing.setter
def page_spacing(self, value: int):
"""
Set the spacing between pages in continuous scroll mode.
Args:
value (int): Spacing in pixels (non-negative integer).
"""
if not isinstance(value, int):
raise ValueError("page_spacing must be an integer")
if value < 0:
raise ValueError("page_spacing must be non-negative")
self._page_spacing = value
# If currently in continuous scroll mode, update the spacing immediately
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
self.pdf_view.setPageSpacing(self._page_spacing)
@SafeProperty(int)
def side_margins(self):
"""Get the horizontal margins (side spacing) around the PDF content."""
return self._side_margins
@side_margins.setter
def side_margins(self, value: int):
"""Set the horizontal margins (side spacing) around the PDF content."""
if not isinstance(value, int):
raise ValueError("side_margins must be an integer")
if value < 0:
raise ValueError("side_margins must be non-negative")
self._side_margins = value
# Update the document margins immediately
# setDocumentMargins takes a QMargins(left, top, right, bottom)
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
def open_file_dialog(self):
"""Open a file dialog to select a PDF file."""
file_path, _ = QFileDialog.getOpenFileName(
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
)
if file_path:
self.load_pdf(file_path)
@SafeSlot(str, popup_error=True)
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
# Validate file exists
if not os.path.isfile(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
self._current_file_path = file_path
# Disconnect any existing signal connections
try:
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
except (TypeError, RuntimeError):
pass
# Connect to statusChanged signal to handle when document is ready
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
# Load the document
self._pdf_document.load(file_path)
# If already ready (synchronous loading), set document immediately
if self._pdf_document.status() == QPdfDocument.Status.Ready:
self._on_document_ready()
@SafeSlot(QPdfDocument.Status)
def _on_document_status_changed(self, status: QPdfDocument.Status):
"""Handle document status changes."""
status = self._pdf_document.status()
if status == QPdfDocument.Status.Ready:
self._on_document_ready()
elif status == QPdfDocument.Status.Error:
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
def _on_document_ready(self):
"""Handle when document is ready to be displayed."""
self.pdf_view.setDocument(self._pdf_document)
# Set initial margins
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
# Connect to page changes to update navigation button states
navigator = self.pdf_view.pageNavigator()
navigator.currentPageChanged.connect(self._on_page_changed)
# Make sure we start at the first page
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
# Update initial navigation state
self._update_navigation_button_states()
self.document_ready.emit(self._current_file_path)
def _on_page_changed(self, _page):
"""Handle page change events to update navigation states."""
self._update_navigation_button_states()
# Toolbar action methods
@SafeSlot()
def zoom_in(self):
"""Zoom in the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = current_factor * 1.25
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def zoom_out(self):
"""Zoom out the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = max(current_factor / 1.25, 0.1)
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def fit_to_width(self):
"""Fit PDF to width."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
@SafeSlot()
def fit_to_page(self):
"""Fit PDF to page."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
@SafeSlot()
def reset_zoom(self):
"""Reset zoom to 100% (1.0 factor)."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
self.pdf_view.setZoomFactor(1.0)
@SafeSlot()
def previous_page(self):
"""Go to previous page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
if current_page == 0:
self._update_navigation_button_states()
return
try:
target_page = current_page - 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() - page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot()
def next_page(self):
"""Go to next page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
max_page = self._pdf_document.pageCount() - 1
if current_page < max_page:
try:
target_page = current_page + 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() + page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot(bool)
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
if checked:
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
self.pdf_view.setPageSpacing(self._page_spacing)
self._update_navigation_buttons_for_mode(continuous=True)
tooltip = "Switch to Single Page Mode"
else:
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
self._update_navigation_buttons_for_mode(continuous=False)
tooltip = "Switch to Continuous Scroll Mode"
# Update navigation button states after mode change
self._update_navigation_button_states()
# Update toggle button tooltip to reflect current state
action = self.toolbar.components.get_action("continuous_scroll")
if action and hasattr(action, "action") and action.action:
action.action.setToolTip(tooltip)
def _update_navigation_buttons_for_mode(self, continuous: bool):
"""Update navigation button tooltips based on current mode."""
prev_action = self.toolbar.components.get_action("prev_page")
next_action = self.toolbar.components.get_action("next_page")
if continuous:
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
else:
prev_actions_tooltip = "Previous Page"
next_actions_tooltip = "Next Page"
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setToolTip(prev_actions_tooltip)
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setToolTip(next_actions_tooltip)
@SafeSlot()
def go_to_first_page(self):
"""Go to the first page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot()
def go_to_last_page(self):
"""Go to the last page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
last_page = self._pdf_document.pageCount() - 1
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot(int)
def jump_to_page(self, page_number: int):
"""Jump to a specific page number (1-based index)."""
if not isinstance(page_number, int):
raise ValueError("page_number must be an integer")
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
raise RuntimeError("No PDF document loaded")
max_page = self._pdf_document.pageCount()
page_number = max(min(page_number, max_page), 1)
target_page = page_number - 1 # Convert to 0-based index
navigator = self.pdf_view.pageNavigator()
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
def cleanup(self):
"""Handle widget close event to prevent segfaults."""
if hasattr(self, "_pdf_document") and self._pdf_document:
self._pdf_document.statusChanged.disconnect()
empty_doc = QPdfDocument(self)
self.pdf_view.setDocument(empty_doc)
if hasattr(self, "toolbar"):
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__":
import sys
# from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
# apply_theme("dark")
viewer = PdfViewerWidget()
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
viewer.next_page()
# viewer.page_spacing = 0
# viewer.side_margins = 0
viewer.resize(1000, 700)
viewer.show()
sys.exit(app.exec())

View File

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

View File

@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
DOM_XML = """
<ui language='c++'>
<widget class='PdfViewerWidget' name='pdf_viewer_widget'>
</widget>
</ui>
"""
class PdfViewerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PdfViewerWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(PdfViewerWidget.ICON_NAME)
def includeFile(self):
return "pdf_viewer_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "PdfViewerWidget"
def toolTip(self):
return "A widget to display PDF documents with toolbar controls."
def whatsThis(self):
return self.toolTip()

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

View File

@@ -0,0 +1,119 @@
(user.widgets.pdf_viewer_widget)=
# PDF Viewer Widget
````{tab} Overview
The PDF Viewer Widget is a versatile tool designed for displaying and navigating PDF documents within your BEC applications. Directly integrated with the `BEC` framework, it provides a full-featured PDF viewing experience with zoom controls, page navigation, and customizable display options.
## Key Features:
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
- **Full PDF Support**: Display any PDF document with full rendering support through Qt's PDF rendering engine.
- **Navigation Controls**: Built-in toolbar with page navigation, zoom controls, and document status indicators.
- **Customizable Display**: Adjustable page spacing, margins, and zoom levels for optimal viewing experience.
- **Document Management**: Load different PDF files dynamically during runtime with proper error handling.
## User Interface Components:
- **Toolbar**: Contains all navigation and zoom controls
- Previous/Next page buttons
- Page number input field with total page count
- First/Last page navigation buttons
- Zoom in/out buttons
- Fit to width/page buttons
- Reset zoom button
- **PDF View Area**: Main display area for the PDF content
````
````{tab} Examples - CLI
`PdfViewerWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
## Example 1 - Basic PDF Loading
In this example, we demonstrate how to add a `PdfViewerWidget` to a [`BECDockArea`](user.widgets.bec_dock_area) and load a PDF document.
```python
# Add a new dock with PDF viewer widget
dock_area = gui.new()
pdf_viewer = dock_area.new().new(gui.available_widgets.PdfViewerWidget)
# Load a PDF file
pdf_viewer.load_pdf("/path/to/your/document.pdf")
```
## Example 2 - Customizing Display Properties
This example shows how to customize the display properties of the PDF viewer for better presentation.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load PDF document
pdf_viewer.load_pdf("/path/to/report.pdf")
pdf_viewer.toggle_continuous_scroll(True) # Enable continuous scroll mode
# Customize display properties
pdf_viewer.page_spacing = 20 # Increase spacing between pages
pdf_viewer.side_margins = 50 # Add horizontal margins
# Navigate to specific page
pdf_viewer.jump_to_page(5) # Go to page 5
```
## Example 3 - Navigation and Zoom Controls
The PDF viewer provides programmatic access to all navigation and zoom functionality.
```python
# Create and load PDF
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
pdf_viewer.load_pdf("/path/to/manual.pdf")
# Navigation examples
pdf_viewer.go_to_first_page() # Go to first page
pdf_viewer.go_to_last_page() # Go to last page
pdf_viewer.jump_to_page(10) # Jump to specific page
# Zoom controls
pdf_viewer.zoom_in() # Increase zoom
pdf_viewer.zoom_out() # Decrease zoom
pdf_viewer.fit_to_width() # Fit document to window width
pdf_viewer.fit_to_page() # Fit entire page to window
pdf_viewer.reset_zoom() # Reset to 100% zoom
# Check current status
current_page = pdf_viewer.current_page
print(f"Currently viewing page {current_page}")
```
## Example 4 - Dynamic Document Loading
This example demonstrates how to switch between different PDF documents dynamically.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load first document
pdf_viewer.load_pdf("/path/to/document1.pdf")
# Or simply set the current file path
pdf_viewer.current_file_path = "/path/to/document2.pdf"
# This automatically loads the new document
# Check which file is currently loaded
current_file = pdf_viewer.current_file_path
print(f"Currently viewing: {current_file}")
```
````
````{tab} API
```{eval-rst}
.. autoclass:: bec_widgets.cli.client.PdfViewerWidget
:members:
:show-inheritance:
```
````

View File

@@ -270,6 +270,14 @@ Select DAP model from a list of DAP processes.
Show and filter logs from the BEC Redis server.
```
```{grid-item-card} PDF Viewer Widget
:link: user.widgets.pdf_viewer_widget
:link-type: ref
:img-top: /assets/widget_screenshots/pdf_viewer.png
Display and navigate PDF documents.
```
````
```{toctree}
@@ -307,6 +315,7 @@ dap_combo_box/dap_combo_box.md
games/games.md
log_panel/log_panel.md
signal_label/signal_label.md
pdf_viewer/pdf_viewer_widget.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.42.1"
version = "2.45.12"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -13,13 +13,13 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client~=3.70", # needed for jupyter console
"bec_ipython_client~=3.70", # needed for jupyter console
"bec_lib~=3.70",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"pyqtgraph==0.13.7",
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
@@ -32,7 +32,6 @@ dependencies = [
dev = [
"coverage~=7.0",
"fakeredis~=2.23, >=2.23.2",
"isort~=5.13, >=5.13.2",
"pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4",
"pytest-random-order~=1.1",

View File

@@ -75,6 +75,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
assert c1._config_dict["signal"] == {
"dap": None,
"name": "bpm4i",
"dap_parameters": None,
"entry": "bpm4i",
"dap_oversample": 1,
}

View File

@@ -12,7 +12,7 @@ from bec_lib.scan_history import ScanHistory
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
def fake_redis_server(host, port):
def fake_redis_server(host, port, **kwargs):
redis = fakeredis.FakeRedis()
return redis

View File

@@ -82,6 +82,18 @@ def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
def test_validate_color_map_accepts_gradient_presets_and_greys_alias():
presets = {p.lower() for p in Colors.list_available_gradient_presets()}
candidate = next(
(p for p in ("grey", "gray", "bipolar", "spectrum", "flame") if p in presets), None
)
if candidate is None:
pytest.skip("No known GradientEditorItem presets available in this environment.")
assert Colors.validate_color_map(candidate) != ""
assert Colors.get_colormap(candidate) is not None
@pytest.mark.parametrize("num", [10, 100, 400])
def test_evenly_spaced_colors(num):
colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor")

View File

@@ -193,21 +193,6 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
assert np.isclose(y, 5)
def test_marker_positions_after_mouse_move(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(event_mock)
marker = crosshair.marker_moved_1d["Curve 1"]
marker_x, marker_y = marker.getData()
assert marker_x == [2]
assert marker_y == [5]
def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair

View File

@@ -134,7 +134,7 @@ def test_update_cycle(update_dialog, qtbot):
"deviceClass": "TestDevice",
"deviceConfig": {"param1": "val1"},
"readoutPriority": "monitored",
"description": None,
"description": "",
"readOnly": False,
"softwareTrigger": False,
"onFailure": "retry",

View File

@@ -4,6 +4,7 @@ import numpy as np
import pytest
from bec_lib import messages
from bec_lib.scan_history import ScanHistory
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
@@ -125,12 +126,16 @@ def test_heatmap_get_image_data_unsupported_scan(heatmap_widget):
def test_heatmap_get_grid_scan_image(heatmap_widget):
x_levels = np.linspace(-5, 5, 10).tolist()
y_levels = np.linspace(-5, 5, 10).tolist()
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={"positions": np.random.rand(100, 2).tolist()},
info={
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
heatmap_widget._image_config = HeatmapConfig(
@@ -145,6 +150,111 @@ def test_heatmap_get_grid_scan_image(heatmap_widget):
assert sorted(np.asarray(img, dtype=int).flatten().tolist()) == list(range(100))
def _grid_positions(
*, slow_levels: list[float], fast_levels: list[float], snaked: bool, slow_is_col0: bool = True
) -> list[list[float]]:
positions: list[list[float]] = []
for slow_i, slow_val in enumerate(slow_levels):
row_fast = fast_levels if (not snaked or slow_i % 2 == 0) else list(reversed(fast_levels))
for fast_val in row_fast:
if slow_is_col0:
positions.append([slow_val, fast_val])
else:
positions.append([fast_val, slow_val])
return positions
def test_heatmap_grid_scan_direction_and_snaking_x_fast(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
# x decreases (relative), y increases (relative), x is fast axis
x0 = 10.0
y0 = -3.0
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
snaked = True
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={
"positions": _grid_positions(slow_levels=y_levels, fast_levels=x_levels, snaked=snaked)
},
request_inputs={
"arg_bundle": ["samy", -2.0, 2.0, 2, "samx", 1.0, -1.0, 3],
"kwargs": {"snaked": snaked, "relative": True},
},
)
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
assert img.shape == (3, 2)
assert img[0, 0] == 0 # first point: (x0,y0) in scan order
assert img[2, 1] == 3 # second row first point due to snaking
assert img[0, 1] == 5 # last point in second row
p0 = transform.map(QPointF(0.5, 0.5))
p1 = transform.map(QPointF(2.5, 1.5))
assert p0.x() == pytest.approx(x_levels[0])
assert p0.y() == pytest.approx(y_levels[0])
assert p1.x() == pytest.approx(x_levels[-1])
assert p1.y() == pytest.approx(y_levels[-1])
def test_heatmap_grid_scan_direction_and_snaking_y_fast(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
# x decreases (relative), y increases (relative), y is fast axis
x0 = 1.5
y0 = 22.0
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
snaked = True
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=snaked)
},
request_inputs={
"arg_bundle": ["samx", 1.0, -1.0, 3, "samy", -2.0, 2.0, 2],
"kwargs": {"snaked": snaked, "relative": True},
},
)
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
assert img.shape == (3, 2)
assert img[0, 0] == 0
# For y-fast scans, snaking reverses the y index on every odd x row.
assert img[1, 1] == 2
assert img[1, 0] == 3
p0 = transform.map(QPointF(0.5, 0.5))
p1 = transform.map(QPointF(2.5, 1.5))
assert p0.x() == pytest.approx(x_levels[0])
assert p0.y() == pytest.approx(y_levels[0])
assert p1.x() == pytest.approx(x_levels[-1])
assert p1.y() == pytest.approx(y_levels[-1])
def test_heatmap_get_step_scan_image(heatmap_widget):
scan_msg = messages.ScanStatusMessage(
@@ -193,12 +303,16 @@ def test_heatmap_update_plot(heatmap_widget):
color_map="viridis",
)
heatmap_widget.scan_item = create_dummy_scan_item()
x_levels = np.linspace(-5, 5, 10).tolist()
y_levels = np.linspace(-5, 5, 10).tolist()
heatmap_widget.scan_item.status_message = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={"positions": np.random.rand(100, 2).tolist()},
info={
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:

View File

@@ -191,51 +191,10 @@ def test_bec_weblinks(monkeypatch):
assert opened_urls == [
"https://beamline-experiment-control.readthedocs.io/en/latest/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://gitlab.psi.ch/groups/bec/-/issues/",
"https://github.com/bec-project/bec_widgets/issues",
]
#################################################################
# Tests for scanprogress bar animations
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
"""
_show_scan_progress_bar should animate the container's maximumWidth
from 0 to the configured target width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# Precondition: collapsed
assert container.maximumWidth() == 0
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
assert container.maximumWidth() == target
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
"""
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# First expand it
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
# Trigger hide animation
bec_main_window._animate_hide_scan_progress_bar()
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
assert container.maximumWidth() == 0
#################################################################
# Tests for hover widget and tooltip behaviour

View File

@@ -0,0 +1,372 @@
import pytest
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
from .client_mocks import mocked_client
@pytest.fixture
def pdf_viewer_widget(qtbot, mocked_client):
"""Create a PDF viewer widget for testing."""
widget = PdfViewerWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.cleanup()
@pytest.fixture
def temp_pdf_file(tmpdir):
"""Create a minimal 3-page PDF file for testing."""
pdf_content = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 1) Tj ET
endstream
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R >>
endobj
6 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 2) Tj ET
endstream
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R >>
endobj
8 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 3) Tj ET
endstream
endobj
9 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 10
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000125 00000 n
0000000205 00000 n
0000000282 00000 n
0000000362 00000 n
0000000439 00000 n
0000000519 00000 n
0000000596 00000 n
trailer
<< /Size 10 /Root 1 0 R >>
startxref
675
%%EOF
"""
pdf_path = tmpdir.join("test_3page.pdf")
pdf_path.write_binary(pdf_content)
return str(pdf_path)
@pytest.fixture
def temp_pdf_file_2(tmpdir):
"""Create a second minimal temporary PDF file for testing."""
# Create a minimal PDF content for testing
pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>stream
BT
/F1 12 Tf
100 700 Td
(Second Test PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000307 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
398
%%EOF"""
# Create temporary PDF file using tmpdir
pdf_file = tmpdir.join("test2.pdf")
pdf_file.write_binary(pdf_content)
return str(pdf_file)
def test_initialization(pdf_viewer_widget: PdfViewerWidget):
"""Test that the widget initializes correctly."""
widget = pdf_viewer_widget
# Check basic widget setup
assert widget is not None
assert hasattr(widget, "pdf_view")
assert hasattr(widget, "toolbar")
assert hasattr(widget, "_pdf_document")
# Check initial state
assert widget._current_file_path is None
assert widget._page_spacing == 5
assert widget._side_margins == 10
# Check PDF view setup
assert isinstance(widget.pdf_view, QPdfView)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Check PDF document setup
assert isinstance(widget._pdf_document, QPdfDocument)
def test_toolbar_setup(pdf_viewer_widget: PdfViewerWidget):
"""Test that toolbar is set up with all expected actions."""
widget = pdf_viewer_widget
toolbar = widget.toolbar
# Check that all expected actions exist
expected_actions = [
"open_file",
"zoom_in",
"zoom_out",
"fit_width",
"fit_page",
"reset_zoom",
"continuous_scroll",
"prev_page",
"next_page",
"page_jump",
]
for action_name in expected_actions:
assert toolbar.components.exists(action_name), f"Action {action_name} not found"
def test_load_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file, temp_pdf_file_2):
"""Test loading a PDF file into the viewer."""
widget = pdf_viewer_widget
# Load the temporary PDF file
widget.load_pdf(temp_pdf_file)
qtbot.wait(100) # Wait for loading
# Check that the document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file
# Load a second PDF file to test reloading
widget.load_pdf(temp_pdf_file_2)
qtbot.wait(100) # Wait for loading
# Check that the new document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file_2
assert widget.current_file_path == temp_pdf_file_2
widget.current_file_path = temp_pdf_file
qtbot.wait(100) # Wait for loading
assert widget.current_file_path == temp_pdf_file
def test_load_invalid_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, tmpdir):
"""Test loading an invalid PDF file into the viewer."""
widget = pdf_viewer_widget
# Try to open a non-existent file
invalid_pdf_file = tmpdir.join("non_existent.pdf")
# Attempt to load the invalid PDF file
with pytest.raises(FileNotFoundError):
widget.load_pdf(str(invalid_pdf_file), _override_slot_params={"raise_error": True})
def test_page_navigation(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test page navigation functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Check initial page
assert widget.current_page == 1
total_pages = widget._pdf_document.pageCount()
assert total_pages >= 1
# Navigate to next page
widget.next_page()
qtbot.wait(300)
assert widget.current_page == 2
# Navigate to previous page
widget.previous_page()
qtbot.wait(300)
assert widget.current_page == 1
# Jump to last page
widget.jump_to_page(total_pages)
qtbot.wait(300)
assert widget.current_page == total_pages
widget.jump_to_page(1)
qtbot.wait(300)
assert widget.current_page == 1
widget.jump_to_page(2)
qtbot.wait(300)
assert widget.current_page == 2
widget.go_to_last_page()
qtbot.wait(300)
assert widget.current_page == total_pages
widget.go_to_first_page()
qtbot.wait(300)
assert widget.current_page == 1
widget.page_input.setText(str(total_pages + 10))
widget.page_input.returnPressed.emit()
qtbot.wait(100)
assert widget.current_page == total_pages
def test_zoom_controls(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test zoom in, zoom out, fit width, fit page, and reset zoom functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial zoom mode should be FitToWidth
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Zoom in
initial_zoom = widget.pdf_view.zoomFactor()
widget.zoom_in()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() > initial_zoom
# Zoom out
zoom_after_in = widget.pdf_view.zoomFactor()
widget.zoom_out()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() < zoom_after_in
# Fit to page
widget.fit_to_page()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitInView
# Fit to width
widget.fit_to_width()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Reset zoom
widget.reset_zoom()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.Custom
def test_page_spacing_and_margins(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test setting page spacing and side margins."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Set and verify page spacing
widget.page_spacing = 20
assert widget.page_spacing == 20
# Set and verify side margins
widget.side_margins = 30
assert widget.side_margins == 30
def test_toggle_continuous_scroll(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test toggling continuous scroll mode."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial mode should be single page
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
# Toggle to continuous scroll
widget.toggle_continuous_scroll(True)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.MultiPage
# Toggle back to single page
widget.toggle_continuous_scroll(False)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
widget.jump_to_page(2)
qtbot.wait(100)
assert widget.current_page == 2

View File

@@ -479,6 +479,94 @@ def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
assert dap_curve.config.signal.dap == "GaussianModel"
def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
"""
Ensure that custom curves can also serve as parents for DAP fits.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
x = np.linspace(-1, 1, 50)
y = np.sin(x)
custom_curve = wf.plot(x=x, y=y, label="custom-curve")
dap_curve = wf.add_dap_curve(device_label=custom_curve.name(), dap_name="GaussianModel")
assert dap_curve.config.source == "dap"
assert dap_curve.config.parent_label == custom_curve.name()
assert dap_curve.config.signal.name == custom_curve.name()
assert dap_curve.config.signal.entry == "custom"
assert dap_curve.config.signal.dap == "GaussianModel"
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
with qtbot.waitSignal(wf.request_dap_update):
c.set_data([7, 8, 9], [10, 11, 12])
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
"""
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-inline", dap="GaussianModel")
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
assert dap_curve is not None
assert dap_curve.config.parent_label == curve.name()
assert dap_curve.config.signal.dap == "GaussianModel"
def test_normalize_dap_parameters_number_dict():
normalized = Waveform._normalize_dap_parameters({"amplitude": 1.0, "center": 2})
assert normalized == {
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False},
"center": {"name": "center", "value": 2.0, "vary": False},
}
def test_normalize_dap_parameters_dict_spec_defaults_vary_false():
normalized = Waveform._normalize_dap_parameters({"sigma": {"value": 0.8, "min": 0.0}})
assert normalized["sigma"]["name"] == "sigma"
assert normalized["sigma"]["value"] == 0.8
assert normalized["sigma"]["min"] == 0.0
assert normalized["sigma"]["vary"] is False
def test_normalize_dap_parameters_invalid_type_raises():
with pytest.raises(TypeError):
Waveform._normalize_dap_parameters(["amplitude", 1.0]) # type: ignore[arg-type]
def test_request_dap_includes_normalized_parameters(qtbot, mocked_client_with_dap, monkeypatch):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
curve = wf.plot(
x=[0, 1, 2],
y=[1, 2, 3],
label="custom-inline-params",
dap="GaussianModel",
dap_parameters={"amplitude": 1.0},
)
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
assert dap_curve is not None
dap_curve.dap_oversample = 3
captured = {}
def capture(topic, msg, *args, **kwargs): # noqa: ARG001
captured["topic"] = topic
captured["msg"] = msg
monkeypatch.setattr(wf.client.connector, "set_and_publish", capture)
wf.request_dap()
msg = captured["msg"]
dap_kwargs = msg.content["config"]["kwargs"]
assert dap_kwargs["oversample"] == 3
assert dap_kwargs["parameters"] == {
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False}
}
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
"""
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,