mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
37 Commits
fix/test-u
...
v2.45.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c98106e594 | ||
| 04f1ff4fe7 | |||
|
|
45ed92494c | ||
| 5fc96bd299 | |||
|
|
1ad5df57fe | ||
| 440e778162 | |||
| fdeb8fcb0f | |||
| 5c90983dd4 | |||
| 4171de1e45 | |||
|
|
f12339e6f9 | ||
| ce8e5f0bec | |||
|
|
7ea9ab5175 | ||
| b72f0dc6e8 | |||
|
|
cb9d429884 | ||
| 0a80bd0a92 | |||
|
|
9bc9d355e2 | ||
| 7d5e702a11 | |||
| 40cbf7fe4f | |||
|
|
7b287c45f2 | ||
| c9455672b5 | |||
|
|
7f06375f9d | ||
| d00d786399 | |||
| a4c465dcaf | |||
|
|
d0e94d0da4 | ||
| bb3cea7fe8 | |||
|
|
3c6aa8e138 | ||
| 198684c65d | |||
| 617f2df2af | |||
|
|
ef83287126 | ||
| d5e6f095fe | |||
| b10efc0f40 | |||
| 44b1dbf911 | |||
|
|
e9d381a18a | ||
|
|
b005542df3 | ||
| 13a9175ba5 | |||
|
|
3f8e60a14f | ||
| 6bc1c3c5f1 |
8
.github/workflows/stale-issues.yml
vendored
8
.github/workflows/stale-issues.yml
vendored
@@ -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
|
||||
|
||||
150
CHANGELOG.md
150
CHANGELOG.md
@@ -1,6 +1,156 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- **rpc_server**: Raise window, even if minimized
|
||||
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
|
||||
|
||||
|
||||
## v2.42.0 (2025-10-21)
|
||||
|
||||
### Features
|
||||
|
||||
211
README.md
211
README.md
@@ -1,5 +1,6 @@
|
||||
# BEC Widgets
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
@@ -10,72 +11,190 @@
|
||||
[](https://conventionalcommits.org)
|
||||
[](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`: drag‑dock, 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 zero‑glue 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.
|
||||
|
||||

|
||||
|
||||
### 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 auto‑connect to BEC/Redis on startup, so your UI is operational immediately.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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 day‑to‑day 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
28
THIRD-PARTY-LICENCES
Normal 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)
|
||||
|
||||
@@ -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]":
|
||||
@@ -5128,9 +5439,9 @@ 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.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -5150,11 +5461,12 @@ class Waveform(RPCBase):
|
||||
**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.
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
|
||||
|
||||
class FakeDevice(BECDevice):
|
||||
"""Fake minimal positioner class for testing."""
|
||||
|
||||
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.signals[self.name]["value"] = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
enabled=True,
|
||||
limits=None,
|
||||
read_value=1.0,
|
||||
readout_priority=ReadoutPriority.MONITORED,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
# self.limits = limits if limits is not None else [0.0, 0.0]
|
||||
self.read_value = read_value
|
||||
self.setpoint_value = read_value
|
||||
self.motor_is_moving_value = 0
|
||||
self._enabled = enabled
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
self.name: {"value": self.read_value},
|
||||
f"{self.name}_setpoint": {"value": self.setpoint_value},
|
||||
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.read_value = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
return 3
|
||||
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self, cached=False):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
self.limits = limits
|
||||
|
||||
def move(self, value, relative=False):
|
||||
"""Simulates moving the device to a new position."""
|
||||
if relative:
|
||||
self.read_value += value
|
||||
else:
|
||||
self.read_value = value
|
||||
# Respect the limits
|
||||
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
|
||||
|
||||
@property
|
||||
def readback(self):
|
||||
return MagicMock(get=MagicMock(return_value=self.read_value))
|
||||
|
||||
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name, enabled=True):
|
||||
super().__init__(name, enabled)
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
|
||||
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
|
||||
FakePositioner("aptrx", limits=None, read_value=4.0),
|
||||
FakePositioner("aptry", limits=None, read_value=5.0),
|
||||
FakeDevice("gauss_bpm"),
|
||||
FakeDevice("gauss_adc1"),
|
||||
FakeDevice("gauss_adc2"),
|
||||
FakeDevice("gauss_adc3"),
|
||||
FakeDevice("bpm4i"),
|
||||
FakeDevice("bpm3a"),
|
||||
FakeDevice("bpm3i"),
|
||||
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
|
||||
FakeDevice("waveform1d"),
|
||||
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
|
||||
Positioner("test", limits=[-10, 10], read_value=2.0),
|
||||
Device("test_device"),
|
||||
]
|
||||
|
||||
|
||||
def check_remote_data_size(widget, plot_name, num_elements):
|
||||
"""
|
||||
Check if the remote data has the correct number of elements.
|
||||
Used in the qtbot.waitUntil function.
|
||||
"""
|
||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||
@@ -1,76 +1,285 @@
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
|
||||
|
||||
class TestableQTimer(QTimer):
|
||||
_instances: list[tuple[QTimer, str]] = []
|
||||
_current_test_name: str = ""
|
||||
class FakeDevice(BECDevice):
|
||||
"""Fake minimal positioner class for testing."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
|
||||
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def check_all_stopped(cls, qtbot):
|
||||
def _is_done_or_deleted(t: QTimer):
|
||||
try:
|
||||
return not t.isActive()
|
||||
except RuntimeError as e:
|
||||
return "already deleted" in e.args[0]
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
try:
|
||||
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
|
||||
except QtBotTimeoutError as exc:
|
||||
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
||||
(t.stop() for t, _ in cls._instances)
|
||||
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
|
||||
cls._instances = []
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.signals[self.name]["value"] = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
|
||||
def qapplication_fixture(qtbot, request, testable_qtimer_class):
|
||||
yield
|
||||
class FakePositioner(BECPositioner):
|
||||
|
||||
if request.node.stash._storage.get("failed"):
|
||||
print("Test failed, skipping cleanup checks")
|
||||
return
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
enabled=True,
|
||||
limits=None,
|
||||
read_value=1.0,
|
||||
readout_priority=ReadoutPriority.MONITORED,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
# self.limits = limits if limits is not None else [0.0, 0.0]
|
||||
self.read_value = read_value
|
||||
self.setpoint_value = read_value
|
||||
self.motor_is_moving_value = 0
|
||||
self._enabled = enabled
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
self.name: {"value": self.read_value},
|
||||
f"{self.name}_setpoint": {"value": self.setpoint_value},
|
||||
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
|
||||
}
|
||||
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
bec_dispatcher.stop_cli_server()
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
testable_qtimer_class.check_all_stopped(qtbot)
|
||||
qapp = QApplication.instance()
|
||||
qapp.processEvents()
|
||||
if hasattr(qapp, "os_listener") and qapp.os_listener:
|
||||
qapp.removeEventFilter(qapp.os_listener)
|
||||
try:
|
||||
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
|
||||
except QtBotTimeoutError as exc:
|
||||
raise TimeoutError(f"Failed to close all widgets: {qapp.topLevelWidgets()}") from exc
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.read_value = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
return 3
|
||||
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self, cached=False):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
self.limits = limits
|
||||
|
||||
def move(self, value, relative=False):
|
||||
"""Simulates moving the device to a new position."""
|
||||
if relative:
|
||||
self.read_value += value
|
||||
else:
|
||||
self.read_value = value
|
||||
# Respect the limits
|
||||
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
|
||||
|
||||
@property
|
||||
def readback(self):
|
||||
return MagicMock(get=MagicMock(return_value=self.read_value))
|
||||
|
||||
|
||||
def rpc_register_fixture():
|
||||
try:
|
||||
yield RPCRegister()
|
||||
finally:
|
||||
RPCRegister.reset_singleton()
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
|
||||
|
||||
def bec_dispatcher_fixture(threads_check):
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
try:
|
||||
yield bec_dispatcher
|
||||
finally:
|
||||
bec_dispatcher.disconnect_all()
|
||||
bec_dispatcher.client.shutdown()
|
||||
bec_dispatcher.stop_cli_server()
|
||||
bec_dispatcher_module.BECDispatcher.reset_singleton()
|
||||
class Device(FakeDevice):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name, enabled=True):
|
||||
super().__init__(name, enabled)
|
||||
|
||||
|
||||
def clean_singleton_fixture():
|
||||
error_popups._popup_utility_instance = None
|
||||
yield
|
||||
class DMMock:
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
|
||||
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
|
||||
FakePositioner("aptrx", limits=None, read_value=4.0),
|
||||
FakePositioner("aptry", limits=None, read_value=5.0),
|
||||
FakeDevice("gauss_bpm"),
|
||||
FakeDevice("gauss_adc1"),
|
||||
FakeDevice("gauss_adc2"),
|
||||
FakeDevice("gauss_adc3"),
|
||||
FakeDevice("bpm4i"),
|
||||
FakeDevice("bpm3a"),
|
||||
FakeDevice("bpm3i"),
|
||||
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
|
||||
FakeDevice("waveform1d"),
|
||||
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
|
||||
Positioner("test", limits=[-10, 10], read_value=2.0),
|
||||
Device("test_device"),
|
||||
]
|
||||
|
||||
|
||||
def check_remote_data_size(widget, plot_name, num_elements):
|
||||
"""
|
||||
Check if the remote data has the correct number of elements.
|
||||
Used in the qtbot.waitUntil function.
|
||||
"""
|
||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -129,16 +129,44 @@ class RPCServer:
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
@@ -201,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:
|
||||
|
||||
@@ -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 human‑readable 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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -206,6 +206,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.")
|
||||
|
||||
|
||||
@@ -67,49 +67,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",
|
||||
@@ -759,9 +718,9 @@ 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.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -850,7 +809,7 @@ 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":
|
||||
if dap is not None and curve.config.source in ("device", "history", "custom"):
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
|
||||
return curve
|
||||
@@ -867,11 +826,12 @@ class Waveform(PlotBase):
|
||||
**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.
|
||||
@@ -881,17 +841,22 @@ class Waveform(PlotBase):
|
||||
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}"
|
||||
@@ -1663,6 +1628,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:
|
||||
@@ -2370,7 +2338,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 +2350,31 @@ 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
|
||||
|
||||
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal file
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal 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 1–100000
|
||||
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())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['pdf_viewer.py']}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal file
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal 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:
|
||||
```
|
||||
````
|
||||
@@ -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
|
||||
|
||||
|
||||
```
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.42.0"
|
||||
version = "2.45.10"
|
||||
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",
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import pytest
|
||||
import qtpy.QtCore
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
|
||||
class TestableQTimer(QTimer):
|
||||
_instances: list[tuple[QTimer, str]] = []
|
||||
_current_test_name: str = ""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
|
||||
|
||||
@classmethod
|
||||
def check_all_stopped(cls, qtbot):
|
||||
def _is_done_or_deleted(t: QTimer):
|
||||
try:
|
||||
return not t.isActive()
|
||||
except RuntimeError as e:
|
||||
return "already deleted" in e.args[0]
|
||||
|
||||
try:
|
||||
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
|
||||
except QtBotTimeoutError as exc:
|
||||
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
||||
(t.stop() for t, _ in cls._instances)
|
||||
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
|
||||
cls._instances = []
|
||||
|
||||
from bec_widgets.tests.utils import TestableQTimer
|
||||
|
||||
# To support 'from qtpy.QtCore import QTimer' syntax we just replace this completely for the test session
|
||||
# see: https://docs.python.org/3/library/unittest.mock.html#where-to-patch
|
||||
|
||||
@@ -6,7 +6,7 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.cli.client import Image, MotorMap, MultiWaveform, ScatterWaveform, Waveform
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCReference
|
||||
from bec_widgets.tests.fake_devices import check_remote_data_size
|
||||
from bec_widgets.tests.utils import check_remote_data_size
|
||||
|
||||
|
||||
def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj):
|
||||
|
||||
@@ -9,10 +9,10 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
from bec_widgets.tests.fake_devices import DEVICES, DMMock, FakePositioner, Positioner
|
||||
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
|
||||
|
||||
@@ -6,9 +6,12 @@ import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
from qtpy.QtWidgets import QMessageBox
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bec_widgets.tests import utils as test_utils
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
@@ -22,22 +25,49 @@ def pytest_runtest_makereport(item, call):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
|
||||
yield from test_utils.qapplication_fixture(qtbot, request, testable_qtimer_class)
|
||||
yield
|
||||
|
||||
# if the test failed, we don't want to check for open widgets as
|
||||
# it simply pollutes the output
|
||||
if request.node.stash._storage.get("failed"):
|
||||
print("Test failed, skipping cleanup checks")
|
||||
return
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
bec_dispatcher.stop_cli_server()
|
||||
|
||||
testable_qtimer_class.check_all_stopped(qtbot)
|
||||
qapp = QApplication.instance()
|
||||
qapp.processEvents()
|
||||
if hasattr(qapp, "os_listener") and qapp.os_listener:
|
||||
qapp.removeEventFilter(qapp.os_listener)
|
||||
try:
|
||||
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
|
||||
except QtBotTimeoutError as exc:
|
||||
raise TimeoutError(f"Failed to close all widgets: {qapp.topLevelWidgets()}") from exc
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def rpc_register():
|
||||
yield from test_utils.rpc_register_fixture()
|
||||
yield RPCRegister()
|
||||
RPCRegister.reset_singleton()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
|
||||
yield from test_utils.bec_dispatcher_fixture(threads_check)
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
yield bec_dispatcher
|
||||
bec_dispatcher.disconnect_all()
|
||||
# clean BEC client
|
||||
bec_dispatcher.client.shutdown()
|
||||
# stop the cli server
|
||||
bec_dispatcher.stop_cli_server()
|
||||
# reinitialize singleton for next test
|
||||
bec_dispatcher_module.BECDispatcher.reset_singleton()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_singleton():
|
||||
yield from test_utils.clean_singleton_fixture()
|
||||
error_popups._popup_utility_instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def abort_button(qtbot, mocked_client):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QDoubleSpinBox, QLineEdit
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import pytest
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
class BECConnectorQObject(BECConnector, QObject): ...
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from unittest import mock
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .test_bec_queue import bec_queue_msg_full
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_queue_msg_full():
|
||||
|
||||
@@ -4,12 +4,13 @@ from unittest import mock
|
||||
import pytest
|
||||
from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import (
|
||||
BECServiceInfoContainer,
|
||||
BECStatusBox,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_status_fixture():
|
||||
|
||||
@@ -4,11 +4,11 @@ from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils import Crosshair
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ from bec_lib.scan_history import ScanHistory
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import (
|
||||
CurveTree,
|
||||
ScanIndexValidator,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
##################################################
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from bec_lib.device import Device
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QTabWidget
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
@@ -15,6 +14,8 @@ from bec_widgets.widgets.services.device_browser.device_item.device_signal_displ
|
||||
SignalDisplay,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QListWidgetItem
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,13 +4,13 @@ import pytest
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
DeviceInputBase,
|
||||
DeviceInputConfig,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import pytest
|
||||
from bec_lib.device import ReadoutPriority
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_input_combobox(qtbot, mocked_client):
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from bec_lib.device import Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.tests.utils import FakeDevice
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
@@ -16,6 +16,7 @@ from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit
|
||||
SignalLineEdit,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
# pytest: disable=unused-import
|
||||
from bec_widgets.tests.client_mocks import create_dummy_scan_item, mocked_client
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
|
||||
|
||||
# pytest: disable=unused-import
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
from .client_mocks import create_dummy_scan_item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def heatmap_widget(qtbot, mocked_client):
|
||||
|
||||
@@ -4,10 +4,10 @@ import numpy as np
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Literal
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
CircularROI,
|
||||
@@ -13,6 +12,7 @@ from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import pyqtgraph as pg
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
##################################################
|
||||
|
||||
@@ -8,10 +8,11 @@ from qtpy.QtGui import QFontMetrics
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
base_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ from unittest import mock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,18 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import LogMessage
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.utility.logpanel._util import replace_escapes, simple_color_format
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
log_time,
|
||||
replace_escapes,
|
||||
simple_color_format,
|
||||
)
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n┃\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
|
||||
|
||||
TEST_LOG_MESSAGES = [
|
||||
|
||||
@@ -5,7 +5,6 @@ from qtpy.QtCore import QEvent, QPoint, QPointF
|
||||
from qtpy.QtGui import QEnterEvent
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QLabel
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
|
||||
HoverWidget,
|
||||
WidgetTooltip,
|
||||
@@ -14,6 +13,7 @@ from bec_widgets.widgets.containers.main_window.addons.scroll_label import Scrol
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@@ -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 scan‑progress 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
|
||||
|
||||
# Pre‑condition: 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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
||||
DARK_PALETTE,
|
||||
@@ -14,6 +13,8 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
|
||||
SeverityKind,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def toast(qtbot):
|
||||
|
||||
372
tests/unit_tests/test_pdf_viewer.py
Normal file
372
tests/unit_tests/test_pdf_viewer.py
Normal 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
|
||||
@@ -1,8 +1,8 @@
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
# pylint: disable=unused-import
|
||||
|
||||
@@ -7,8 +7,7 @@ from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.tests.fake_devices import Positioner
|
||||
from bec_widgets.tests.utils import Positioner
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import (
|
||||
PositionerBox,
|
||||
PositionerControlLine,
|
||||
@@ -17,6 +16,7 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
|
||||
DeviceLineEdit,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QMessageBox
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_button(qtbot, mocked_client):
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resume_button(qtbot, mocked_client):
|
||||
|
||||
@@ -4,12 +4,13 @@ import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import ValidationError
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConnections, RingConfig
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBarConfig
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_progress_bar(qtbot, mocked_client):
|
||||
|
||||
@@ -7,11 +7,12 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
|
||||
from qtpy.QtCore import QModelIndex, Qt
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=missing-function-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
@@ -2,9 +2,10 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import ScanHistoryMessage, _StoredDataInfo
|
||||
from pytestqt import qtbot
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
@@ -14,6 +15,8 @@ from bec_widgets.widgets.services.scan_history_browser.scan_history_browser impo
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_msg():
|
||||
|
||||
@@ -4,7 +4,6 @@ import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
|
||||
BECProgressBar,
|
||||
ProgressState,
|
||||
@@ -15,6 +14,8 @@ from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
|
||||
ScanProgressBar,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_progressbar(qtbot, mocked_client):
|
||||
|
||||
@@ -2,12 +2,12 @@ import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.tests.client_mocks import create_dummy_scan_item, mocked_client
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
|
||||
ScatterCurveConfig,
|
||||
ScatterDeviceSignal,
|
||||
)
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtWidgets import QDialogButtonBox
|
||||
from qtpy.QtWidgets import QDialogButtonBox, QLabel
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
SAMX_INFO_DICT = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stop_button(qtbot, mocked_client):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.editors.text_box.text_box import DEFAULT_TEXT, TextBox
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def text_box_widget(qtbot, mocked_client):
|
||||
|
||||
@@ -3,10 +3,10 @@ from unittest import mock
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_arrow_item(qtbot, mocked_client):
|
||||
|
||||
@@ -5,9 +5,10 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vscode_widget(qtbot, mocked_client):
|
||||
|
||||
@@ -12,7 +12,13 @@ from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
|
||||
|
||||
from bec_widgets.tests.client_mocks import (
|
||||
from bec_widgets.widgets.plots.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
from tests.unit_tests.client_mocks import (
|
||||
DummyData,
|
||||
create_dummy_scan_item,
|
||||
dap_plugin_message,
|
||||
@@ -20,12 +26,6 @@ from bec_widgets.tests.client_mocks import (
|
||||
mocked_client,
|
||||
mocked_client_with_dap,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
@@ -479,6 +479,43 @@ 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_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,
|
||||
|
||||
@@ -3,9 +3,10 @@ from unittest import mock
|
||||
import pytest
|
||||
from qtpy.QtNetwork import QAuthenticator
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot, mocked_client):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QUrl
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.editors.website.website import WebsiteWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def website_widget(qtbot, mocked_client):
|
||||
|
||||
Reference in New Issue
Block a user