mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
171 Commits
feat/user_
...
feature/rp
| Author | SHA1 | Date | |
|---|---|---|---|
| cc91e51ee7 | |||
| 93c38eedd6 | |||
| 525a530b98 | |||
| b86fa5bf92 | |||
| 94962fe34f | |||
| a9b36a4c45 | |||
| d90a383e29 | |||
| 925639d5d5 | |||
| 6a7ec51a99 | |||
| 5f11a44f27 | |||
| 3029433740 | |||
| a7020553f9 | |||
| b51bbe5ea9 | |||
| 9491ec6f10 | |||
| f2a69e5c52 | |||
| 1f4e740dca | |||
| 705f157c04 | |||
| 4736c2fad1 | |||
| 31b40aeede | |||
| 77e8a5c884 | |||
| 0f4365bbb0 | |||
| 906ca03929 | |||
| 1206069a8f | |||
| 86487a5f4d | |||
| 4bdcae7028 | |||
| 81f61f3c3b | |||
| 89e8ebf1b6 | |||
|
|
66f4f9bfa8 | ||
| 66c6c7fa50 | |||
|
|
31c3337300 | ||
| 2c506ee3c8 | |||
|
|
25423f4a3a | ||
| fa91366dcb | |||
|
|
4db0f9f10c | ||
| 46b1a228be | |||
|
|
531018b0ac | ||
| 8679b5f08b | |||
| 6f2c2401ac | |||
| 6d1106e33e | |||
| 90a184643a | |||
| 3aa2f2225f | |||
|
|
f54e69f1cf | ||
| 7309c1dede | |||
| 1c0021f98b | |||
| d32952a0d5 | |||
| 5206528fec | |||
| 42665b69c5 | |||
|
|
209c898e3d | ||
| 6a43554f3b | |||
|
|
95c931af0b | ||
| f19d9485df | |||
|
|
575c988c4f | ||
| 6b08f7cfb2 | |||
| 6ae33a23a6 | |||
| facb8c30ff | |||
| 333570ba2f | |||
| ef36a7124d | |||
| c2c022154b | |||
| 4c4f1592c2 | |||
|
|
d7fb291877 | ||
| ae18279685 | |||
| 97c0ed53df | |||
| ff8e282034 | |||
|
|
440f36f289 | ||
| 0addef5f17 | |||
|
|
8c2a5e61fc | ||
| 056731c9ad | |||
|
|
911c81a167 | ||
| 8651314d93 | |||
| 383936ffc2 | |||
|
|
4378d33880 | ||
| 1708bd405f | |||
| 12811eccdb | |||
|
|
5959fa87de | ||
| b3217b7ca5 | |||
|
|
35b941d054 | ||
| fc6d7c0824 | |||
| fb051865d5 | |||
|
|
8aba3d975f | ||
| 5e3289f5bd | |||
| d07744397e | |||
|
|
dc7bf6b3c4 | ||
| f219c6fb57 | |||
| f048880277 | |||
| 50a572dacd | |||
|
|
b87549ba99 | ||
| f0c4efefa0 | |||
| db70442cc2 | |||
|
|
07b8910686 | ||
| e7c97290cd | |||
| 48fc63d83e | |||
| a20935e862 | |||
| 4f8e6835fe | |||
|
|
042adfa51e | ||
| b2b0450bcb | |||
|
|
12e06fa971 | ||
| 6f2f2aa06a | |||
|
|
21965a0ee3 | ||
| 6df57103bb | |||
| 9a8cc31f6c | |||
| d2ffddb6d8 | |||
| 3770db51be | |||
| 2419521f5f | |||
|
|
80937cba97 | ||
| df961a9b88 | |||
| 219d43d325 | |||
|
|
229833eb99 | ||
| 141e1a34c9 | |||
|
|
d40075f85b | ||
| dfa2908c3d | |||
| 02a4862afd | |||
|
|
13438e22d3 | ||
| 889ea8629f | |||
|
|
0ef509e9ca | ||
| b40d2c5f0b | |||
|
|
6cd7ff6ef7 | ||
| 0fd5dd5a26 | |||
| 508abfa8a5 | |||
| 001e6fc807 | |||
|
|
111dcef35a | ||
| 3b04b985b6 | |||
|
|
5944626d93 | ||
| a00d368c25 | |||
| 01b4608331 | |||
|
|
b7221d1151 | ||
| fa9ecaf433 | |||
|
|
c751d25f85 | ||
| e2c7dc98d2 | |||
| 507d46f88b | |||
| 57dc1a3afc | |||
|
|
6a78da0e71 | ||
| fb545eebb3 | |||
| b4a240e463 | |||
| 54e64c9f10 | |||
| 1c8b06cbe6 | |||
| 52c5286d64 | |||
| c405421db9 | |||
| 0ff0c06bd1 | |||
| 955cc64257 | |||
| 09cb08a233 | |||
| 5c83702382 | |||
| 1b0382524f | |||
| 92b802021f | |||
| 48c140f937 | |||
| 42fd78df40 | |||
| 271a4a24e7 | |||
| 1b03ded906 | |||
| bde5618699 | |||
| 6f2eb6b4cd | |||
| 2742a3c6cf | |||
| 809e654087 | |||
| bdb25206d9 | |||
| bd5414288c | |||
| 95f6a7ceb7 | |||
|
|
b75c4c88fe | ||
| e38048964f | |||
|
|
ce11d1382c | ||
| ff654b56ae | |||
| a434d3ee57 | |||
|
|
b467b29f77 | ||
| 17a63e3b63 | |||
|
|
66fc5306d6 | ||
| 6563abfddc | |||
|
|
0d470ddf05 | ||
| 9b95b5d616 | |||
| c7d7c6d9ed | |||
|
|
4686a643f5 | ||
| 9370351abb | |||
| a55134c3bf | |||
| 5fdb2325ae | |||
| 6a36ca512d |
@@ -61,6 +61,7 @@ stages:
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
@@ -77,9 +78,9 @@ formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install black isort
|
||||
- isort --check --diff ./
|
||||
- black --check --diff --color ./
|
||||
- pip install bec_lib[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
@@ -147,7 +148,7 @@ tests:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pip install -e .[dev,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
@@ -171,7 +172,6 @@ test-matrix:
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
@@ -210,7 +210,7 @@ end-2-end-conda:
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pip install -e .[dev,pyside6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
|
||||
|
||||
6131
CHANGELOG.md
6131
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
34
README.md
34
README.md
@@ -1,12 +1,17 @@
|
||||
# BEC Widgets
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
|
||||
## Installation
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets PyQt6
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
@@ -14,22 +19,12 @@ For development purposes, you can clone the repository and install the package l
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
pip install -e .[dev,pyside6]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
## 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/).
|
||||
@@ -39,7 +34,7 @@ Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
@@ -53,13 +48,13 @@ All commits should use the Angular commit scheme:
|
||||
> │
|
||||
> └─⫸ 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
|
||||
@@ -71,4 +66,5 @@ All commits should use the Angular commit scheme:
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
|
||||
@@ -27,25 +27,17 @@ class AutoUpdates:
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
self.msg_queue = Queue()
|
||||
self.auto_update_thread = None
|
||||
self._shutdown_sentinel = object()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the auto update thread.
|
||||
"""
|
||||
self.auto_update_thread = threading.Thread(target=self.process_queue)
|
||||
self.auto_update_thread.start()
|
||||
self._default_dock = None
|
||||
self._default_fig = None
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
dock = self.gui.add_dock("default_figure")
|
||||
dock.add_widget("BECFigure")
|
||||
self.dock_name = "default_figure"
|
||||
self._default_dock = self.gui.new(self.dock_name)
|
||||
self._default_dock.new("BECFigure")
|
||||
self._default_fig = self._default_dock.elements_list[0]
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
@@ -73,15 +65,9 @@ class AutoUpdates:
|
||||
"""
|
||||
Get the default figure from the GUI.
|
||||
"""
|
||||
dock = self.gui.panels.get(self.dock_name, [])
|
||||
if not dock:
|
||||
return None
|
||||
widgets = dock.widget_list
|
||||
if not widgets:
|
||||
return None
|
||||
return widgets[0]
|
||||
return self._default_fig
|
||||
|
||||
def run(self, msg):
|
||||
def do_update(self, msg):
|
||||
"""
|
||||
Run the update function if enabled.
|
||||
"""
|
||||
@@ -90,20 +76,9 @@ class AutoUpdates:
|
||||
if msg.status != "open":
|
||||
return
|
||||
info = self.get_scan_info(msg)
|
||||
self.handler(info)
|
||||
return self.handler(info)
|
||||
|
||||
def process_queue(self):
|
||||
"""
|
||||
Process the message queue.
|
||||
"""
|
||||
while True:
|
||||
msg = self.msg_queue.get()
|
||||
if msg is self._shutdown_sentinel:
|
||||
break
|
||||
self.run(msg)
|
||||
|
||||
@staticmethod
|
||||
def get_selected_device(monitored_devices, selected_device):
|
||||
def get_selected_device(self, monitored_devices, selected_device):
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
@@ -120,14 +95,11 @@ class AutoUpdates:
|
||||
Default update function.
|
||||
"""
|
||||
if info.scan_name == "line_scan" and info.scan_report_devices:
|
||||
self.simple_line_scan(info)
|
||||
return
|
||||
return self.simple_line_scan(info)
|
||||
if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
self.simple_grid_scan(info)
|
||||
return
|
||||
return self.simple_grid_scan(info)
|
||||
if info.scan_report_devices:
|
||||
self.best_effort(info)
|
||||
return
|
||||
return self.best_effort(info)
|
||||
|
||||
def simple_line_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -137,12 +109,19 @@ class AutoUpdates:
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -153,12 +132,18 @@ class AutoUpdates:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = info.scan_report_devices[1]
|
||||
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_z = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
z_name=dev_z,
|
||||
label=f"Scan {info.scan_number} - {dev_z}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def best_effort(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -168,17 +153,16 @@ class AutoUpdates:
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the auto update thread.
|
||||
"""
|
||||
self.msg_queue.put(self._shutdown_sentinel)
|
||||
if self.auto_update_thread:
|
||||
self.auto_update_thread.join()
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
@@ -8,58 +10,44 @@ import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
|
||||
|
||||
def rpc_call(func):
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
Filter out the output from the process.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
if "IMKClient" in output:
|
||||
# only relevant on macOS
|
||||
# see https://discussions.apple.com/thread/255761734?sortBy=rank
|
||||
return ""
|
||||
return output
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
@@ -75,6 +63,7 @@ def _get_output(process, logger) -> None:
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
output = _filter_output(output)
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
@@ -83,7 +72,9 @@ def _get_output(process, logger) -> None:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
|
||||
def _start_plot_process(
|
||||
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
|
||||
) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
@@ -92,7 +83,16 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
@@ -127,34 +127,138 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
|
||||
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client._gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
# after initial waiting period, do not wait so much any more
|
||||
# (only relevant if GUI didn't start)
|
||||
client._startup_timeout = 0
|
||||
yield
|
||||
|
||||
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return f""
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return "" # f"<{self.__class__.__name__}>"
|
||||
|
||||
|
||||
class BECDockArea(client.BECDockArea):
|
||||
"""Extend the BECDockArea class and add namespaces to access widgets of docks."""
|
||||
|
||||
def __init__(self, gui_id=None, config=None, name=None, parent=None):
|
||||
super().__init__(gui_id, config, name, parent)
|
||||
# Add namespaces for DockArea
|
||||
self.elements = WidgetNameSpace()
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
|
||||
_top_level = {}
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._default_dock_name = "bec"
|
||||
self._auto_updates_enabled = True
|
||||
self._auto_updates = None
|
||||
self._killed = False
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
self._exposed_dock_areas = []
|
||||
self._registry_state = {}
|
||||
self._ipython_registry = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
|
||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
self._gui_id = gui_id
|
||||
# Get the registry state
|
||||
msgs = self._client.connector.xread(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), count=1
|
||||
)
|
||||
if msgs:
|
||||
self._handle_registry_update(msgs[0])
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
|
||||
@property
|
||||
def auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
self._gui_started_event.wait()
|
||||
return self._auto_updates
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock ares in the GUI."""
|
||||
return self._top_level
|
||||
|
||||
def shutdown_auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is not None:
|
||||
self._auto_updates.shutdown()
|
||||
self._auto_updates = None
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
|
||||
# FIXME AUTO UPDATES
|
||||
# @property
|
||||
# def auto_updates(self):
|
||||
# if self._auto_updates_enabled:
|
||||
# with wait_for_server(self):
|
||||
# return self._auto_updates
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
@@ -165,70 +269,90 @@ class BECGuiClientMixin:
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self)
|
||||
return ep.load()(gui=self._top_level["main"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
# FIXME AUTO UPDATES
|
||||
# @property
|
||||
# def selected_device(self) -> str | None:
|
||||
# """
|
||||
# Selected device for the plot.
|
||||
# """
|
||||
# auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
|
||||
# auto_update_config = self._client.connector.get(auto_update_config_ep)
|
||||
# if auto_update_config:
|
||||
# return auto_update_config.selected_device
|
||||
# return None
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | DeviceBase):
|
||||
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
self._selected_device = device.name
|
||||
elif isinstance(device, str):
|
||||
self._selected_device = device
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
# @selected_device.setter
|
||||
# def selected_device(self, device: str | DeviceBase):
|
||||
# if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
# self._client.connector.set_and_publish(
|
||||
# MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
# messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
|
||||
# )
|
||||
# elif isinstance(device, str):
|
||||
# self._client.connector.set_and_publish(
|
||||
# MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
# messages.GUIAutoUpdateConfigMessage(selected_device=device),
|
||||
# )
|
||||
# else:
|
||||
# raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
)
|
||||
# FIXME AUTO UPDATES
|
||||
# def _start_update_script(self) -> None:
|
||||
# self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
|
||||
|
||||
@staticmethod
|
||||
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
|
||||
if parent.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
parent._update_script_msg_parser(msg.value)
|
||||
# def _handle_msg_update(self, msg: StreamMessage) -> None:
|
||||
# if self.auto_updates is not None:
|
||||
# # pylint: disable=protected-access
|
||||
# return self._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
if self._auto_updates_enabled:
|
||||
self.auto_updates.msg_queue.put(msg)
|
||||
# def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
# if isinstance(msg, messages.ScanStatusMessage):
|
||||
# if not self._gui_is_alive():
|
||||
# return
|
||||
# if self._auto_updates_enabled:
|
||||
# return self.auto_updates.do_update(msg)
|
||||
|
||||
def _gui_post_startup(self):
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is None:
|
||||
auto_updates = self._get_update_script()
|
||||
if auto_updates is None:
|
||||
AutoUpdates.create_default_dock = True
|
||||
AutoUpdates.enabled = True
|
||||
auto_updates = AutoUpdates(gui=self)
|
||||
if auto_updates.create_default_dock:
|
||||
auto_updates.start_default_dock()
|
||||
# fig = auto_updates.get_default_figure()
|
||||
self._auto_updates = auto_updates
|
||||
timeout = 10
|
||||
while time.time() < time.time() + timeout:
|
||||
if len(list(self._registry_state.keys())) == 0:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
# FIXME AUTO UPDATES
|
||||
# if self._auto_updates_enabled:
|
||||
# if self._auto_updates is None:
|
||||
# auto_updates = self._get_update_script()
|
||||
# if auto_updates is None:
|
||||
# AutoUpdates.create_default_dock = True
|
||||
# AutoUpdates.enabled = True
|
||||
# auto_updates = AutoUpdates(self._top_level[name])
|
||||
# if auto_updates.create_default_dock:
|
||||
# auto_updates.start_default_dock()
|
||||
# self._start_update_script()
|
||||
# self._auto_updates = auto_updates
|
||||
self._do_show_all()
|
||||
self._gui_started_event.set()
|
||||
self.show_all()
|
||||
|
||||
def start_server(self, wait=False) -> None:
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
|
||||
self._gui_id,
|
||||
self.__class__,
|
||||
gui_class_id=self._default_dock_name,
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
def gui_started_callback(callback):
|
||||
@@ -239,27 +363,210 @@ class BECGuiClientMixin:
|
||||
threading.current_thread().cancel()
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def show_all(self):
|
||||
self._gui_started_event.wait()
|
||||
def _dump(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("show")
|
||||
return rpc_client._run_rpc("_dump")
|
||||
|
||||
def hide_all(self):
|
||||
self._gui_started_event.wait()
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
return self._start(wait=wait)
|
||||
|
||||
def _handle_registry_update(self, msg: StreamMessage) -> None:
|
||||
self._registry_state = msg["data"].state
|
||||
self._update_dynamic_namespace()
|
||||
# self._update_dynamic_namespace()
|
||||
# FIXME logic to update namespace
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
def close(self) -> None:
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
# because of the registry callbacks, we may have
|
||||
# dock areas that are already killed, but not yet
|
||||
# removed from the registry state
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
if self._process is not None:
|
||||
return self._show_all()
|
||||
# backward compatibility: show() was also starting server
|
||||
return self._start_server(wait=True)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
) -> BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
|
||||
Returns:
|
||||
BECDockArea: The new dock area.
|
||||
"""
|
||||
Close the gui window.
|
||||
if len(self.window_list) == 0:
|
||||
self.show()
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows.keys():
|
||||
self.delete(widget_name)
|
||||
|
||||
def _clear_top_level_widgets(self):
|
||||
self._top_level.clear()
|
||||
for widget_id in self._exposed_dock_areas:
|
||||
delattr(self, widget_id)
|
||||
self._exposed_dock_areas.clear()
|
||||
|
||||
def _add_dock_areas_from_registry(self):
|
||||
existing_gui_ids = []
|
||||
for dock_area_info in self._registry_state.values():
|
||||
name = dock_area_info["name"]
|
||||
gui_id = dock_area_info["gui_id"]
|
||||
obj = self._ipython_registry.get("gui_id")
|
||||
if obj is None:
|
||||
dock_area = BECDockArea(gui_id=gui_id, name=name, parent=self)
|
||||
self._top_level[name] = dock_area
|
||||
# weak ref to for all widgets
|
||||
self._ipython_registry[gui_id] = dock_area
|
||||
else:
|
||||
# update namespace
|
||||
dock_area = obj
|
||||
existing_gui_ids.append(gui_id)
|
||||
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
self._top_level[name] = obj
|
||||
self._exposed_dock_areas.append(name)
|
||||
setattr(self, name, obj)
|
||||
|
||||
dock_info = dock_area_info["config"].get("docks", None)
|
||||
if dock_info:
|
||||
self._add_docks_from_registry(dock_info, dock_area, gui_ids=existing_gui_ids)
|
||||
|
||||
remove_ids = []
|
||||
for widget_id in self._ipython_registry:
|
||||
if widget_id not in existing_gui_ids:
|
||||
remove_ids.append(widget_id)
|
||||
for widget_id in remove_ids:
|
||||
self._ipython_registry.pop(widget_id)
|
||||
|
||||
def _add_docks_from_registry(
|
||||
self, dock_info: dict[str, dict], dock_area: BECDockArea, gui_ids: list[str]
|
||||
):
|
||||
for dock_name, info in dock_info.items():
|
||||
# add the gui_id to the list of existing gui_ids
|
||||
gui_ids.append(info["gui_id"])
|
||||
obj = self._ipython_registry.get(info["gui_id"])
|
||||
if obj is None:
|
||||
# create new rpc object
|
||||
dock = client.BECDock(gui_id=info["gui_id"], name=dock_name, parent=dock_area)
|
||||
# add reference to the registry
|
||||
self._ipython_registry[info["gui_id"]] = dock
|
||||
else:
|
||||
dock = obj
|
||||
# create weak reference for the namespace
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=info["gui_id"])
|
||||
# add the dock to the dock area
|
||||
setattr(dock_area, dock_name, obj)
|
||||
widget_info = info["widgets"]
|
||||
if widget_info:
|
||||
self._add_widgets_from_registry(
|
||||
widget_info=widget_info, dock_area=dock_area, dock=dock, gui_ids=gui_ids
|
||||
)
|
||||
|
||||
def _add_widgets_from_registry(
|
||||
self,
|
||||
widget_info: dict[str, dict],
|
||||
dock_area: client.BECDockArea,
|
||||
dock: client.BECDock,
|
||||
gui_ids: list[str],
|
||||
):
|
||||
for widget_name, info in widget_info.items():
|
||||
gui_ids.append(info["gui_id"])
|
||||
obj = self._ipython_registry.get(info["gui_id"])
|
||||
if obj is None:
|
||||
widget_class = getattr(client, info["widget_class"])
|
||||
widget = widget_class(gui_id=info["gui_id"], name=widget_name, parent=dock)
|
||||
self._ipython_registry[info["gui_id"]] = widget
|
||||
else:
|
||||
widget = obj
|
||||
dock_area_elements = getattr(dock_area, "elements")
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=info["gui_id"])
|
||||
setattr(dock_area_elements, widget_name, obj)
|
||||
setattr(dock, widget_name, obj)
|
||||
|
||||
def _update_dynamic_namespace(self):
|
||||
"""Update the dynamic name space"""
|
||||
self._clear_top_level_widgets()
|
||||
self._add_dock_areas_from_registry()
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
self._top_level.clear()
|
||||
self._killed = True
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
self._gui_started_timer.join()
|
||||
@@ -274,130 +581,7 @@ class BECGuiClientMixin:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
self.shutdown_auto_updates()
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(10)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
@@ -43,14 +43,21 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
Generate the client for the published classes, skipping any classes
|
||||
that have `RPC = False`.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
# Filter out classes that explicitly have RPC=False
|
||||
rpc_top_level_classes = [
|
||||
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
connector_classes = class_container.connector_classes
|
||||
|
||||
connector_classes = [
|
||||
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
@@ -81,16 +88,28 @@ class Widgets(str, enum.Enum):
|
||||
|
||||
class_name = cls.__name__
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
if class_name == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
@@ -100,8 +119,10 @@ class {class_name}(RPCBase):"""
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list."
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
f"Please check the USER_ACCESS list."
|
||||
)
|
||||
|
||||
if isinstance(obj, (property, QtProperty)):
|
||||
# for the cli, we can map qt properties to regular properties
|
||||
if is_property_setter:
|
||||
|
||||
245
bec_widgets/cli/rpc/rpc_base.py
Normal file
245
bec_widgets/cli/rpc/rpc_base.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class DeletedWidgetError(Exception): ...
|
||||
|
||||
|
||||
def check_for_deleted_widget(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCReference:
|
||||
def __init__(self, registry: dict, gui_id: str) -> None:
|
||||
self._registry = registry
|
||||
self._gui_id = gui_id
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getattr__(self, name):
|
||||
if name in ["_registry", "_gui_id"]:
|
||||
return super().__getattribute__(name)
|
||||
return self._registry[self._gui_id].__getattribute__(name)
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getitem__(self, key):
|
||||
return self._registry[self._gui_id].__getitem__(key)
|
||||
|
||||
def __repr__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__repr__()
|
||||
|
||||
def __str__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__str__()
|
||||
|
||||
def __dir__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return []
|
||||
return self._registry[self._gui_id].__dir__()
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
name: str | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self._name = name if name is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.widget_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(timeout)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
ret = cls(parent=self, **msg_result)
|
||||
self._root._ipython_registry[ret._gui_id] = ret
|
||||
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
|
||||
return obj
|
||||
# return ret
|
||||
return msg_result
|
||||
|
||||
def _gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
173
bec_widgets/cli/rpc/rpc_register.py
Normal file
173
bec_widgets/cli/rpc/rpc_register.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def broadcast_update(func):
|
||||
"""
|
||||
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
result = func(self, *args, **kwargs)
|
||||
self.broadcast()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._initialized = True
|
||||
self.callbacks = []
|
||||
|
||||
@broadcast_update
|
||||
def add_rpc(self, rpc: QObject):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
@broadcast_update
|
||||
def remove_rpc(self, rpc: str):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject | None: The RPC object with the given ID or None
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def get_rpc_by_name(self, name: str) -> QObject | None:
|
||||
"""
|
||||
Get an RPC object by its name.
|
||||
|
||||
Args:
|
||||
name(str): The name of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject | None: The RPC object with the given name.
|
||||
"""
|
||||
rpc_object = [rpc for rpc in self._rpc_register if rpc._name == name]
|
||||
rpc_object = rpc_object[0] if len(rpc_object) > 0 else None
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
Args:
|
||||
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
|
||||
"""
|
||||
# This retrieves any rpc objects that are subclass of BECWidget,
|
||||
# i.e. curve and image items are excluded
|
||||
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
|
||||
return [widget._name for widget in widgets]
|
||||
|
||||
# def get_names_by_class_name(self, class_name: str) -> list[str]:
|
||||
# """
|
||||
# Get all RPC objects of a class, i.e. BECDockArea, BECDock.
|
||||
|
||||
# Args:
|
||||
# class_name(str): The type of the RPC object to be retrieved.
|
||||
|
||||
# Returns:
|
||||
# list: A list of names of RPC objects of the given type.
|
||||
# """
|
||||
# rpc_objects = [
|
||||
# rpc._name
|
||||
# for name, rpc in self._rpc_register.items()
|
||||
# if rpc.__class__.__name__ == class_name
|
||||
# ]
|
||||
# return rpc_objects
|
||||
|
||||
def broadcast(self):
|
||||
"""
|
||||
Broadcast the update to all the callbacks.
|
||||
"""
|
||||
# print("Broadcasting")
|
||||
connections = self.list_all_connections()
|
||||
for callback in self.callbacks:
|
||||
callback(connections)
|
||||
|
||||
def add_callback(self, callback: Callable[[dict], None]):
|
||||
"""
|
||||
Add a callback that will be called whenever the registry is updated.
|
||||
|
||||
Args:
|
||||
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
|
||||
registered RPC objects as an argument.
|
||||
"""
|
||||
self.callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from typing import Any
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
@@ -10,7 +13,7 @@ class RPCWidgetHandler:
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self):
|
||||
def widget_classes(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
@@ -19,7 +22,7 @@ class RPCWidgetHandler:
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes
|
||||
return self._widget_classes # type: ignore
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
@@ -31,24 +34,27 @@ class RPCWidgetHandler:
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
|
||||
self._widget_classes = {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECConnector): The created widget.
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
widget_class = self._widget_classes.get(widget_type) # type: ignore
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
return widget_class(name=name, **kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import Lock
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._initialized = True
|
||||
|
||||
def add_rpc(self, rpc: QObject):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
def remove_rpc(self, rpc: str):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject: The RPC object with the given ID.
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import types
|
||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -11,8 +13,11 @@ from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc import rpc_register
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
@@ -23,6 +28,29 @@ messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
# get error popup utility singleton
|
||||
popup = ErrorPopupUtility()
|
||||
# save current setting
|
||||
old_exception_hook = popup.custom_exception_hook
|
||||
|
||||
# install err_func, if it is a callable
|
||||
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
|
||||
# of the ErrorPopupUtility (popup instance) class.
|
||||
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
||||
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
||||
|
||||
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
|
||||
|
||||
try:
|
||||
yield popup
|
||||
finally:
|
||||
# restore state of error popup utility singleton
|
||||
popup.custom_exception_hook = old_exception_hook
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
@@ -31,17 +59,17 @@ class BECWidgetsCLIServer:
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
|
||||
gui_class_id: str = "bec",
|
||||
) -> None:
|
||||
self.status = messages.BECStatus.BUSY
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.gui = gui_class(gui_id=self.gui_id)
|
||||
# register broadcast callback
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
|
||||
self.rpc_register.add_callback(self.broadcast_registry_update)
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
@@ -53,22 +81,25 @@ class BECWidgetsCLIServer:
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
# Create initial object -> BECFigure or BECDockArea
|
||||
self.gui = gui_class(parent=None, name=gui_class_id)
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
@@ -109,6 +140,9 @@ class BECWidgetsCLIServer:
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"name": (
|
||||
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
|
||||
), # pylint: disable=protected-access
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
@@ -117,10 +151,31 @@ class BECWidgetsCLIServer:
|
||||
|
||||
def emit_heartbeat(self):
|
||||
logger.trace(f"Emitting heartbeat for {self.gui_id}")
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
try:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
)
|
||||
except RedisError as exc:
|
||||
logger.error(f"Error while emitting heartbeat: {exc}")
|
||||
|
||||
def broadcast_registry_update(self, connections: dict):
|
||||
"""
|
||||
Broadcast the updated registry to all clients.
|
||||
"""
|
||||
|
||||
# We only need to broadcast the dock areas
|
||||
data = {
|
||||
key: self.serialize_object(val)
|
||||
for key, val in connections.items()
|
||||
if val.__class__.__name__ == "BECDockArea"
|
||||
}
|
||||
logger.info(f"All registered connections: {list(connections.keys())}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1, # only single message in stream
|
||||
)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
@@ -150,7 +205,12 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
return
|
||||
|
||||
|
||||
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
|
||||
def _start_server(
|
||||
gui_id: str,
|
||||
gui_class: Union[BECFigure, BECDockArea],
|
||||
gui_class_id: str = "bec",
|
||||
config: str | None = None,
|
||||
):
|
||||
if config:
|
||||
try:
|
||||
config = json.loads(config)
|
||||
@@ -167,7 +227,9 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
|
||||
# service_name="BECWidgetsCLIServer",
|
||||
# service_config=service_config.service_config,
|
||||
# )
|
||||
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
|
||||
server = BECWidgetsCLIServer(
|
||||
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@@ -181,28 +243,34 @@ def main():
|
||||
|
||||
import bec_widgets
|
||||
|
||||
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
||||
if __name__ != "__main__":
|
||||
# if not running as main, set the log level to critical
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
parser.add_argument("--id", type=str, default="test", help="The id of the server")
|
||||
parser.add_argument(
|
||||
"--gui_class",
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui_class_id",
|
||||
type=str,
|
||||
default="bec",
|
||||
help="The id of the gui class that is added to the QApplication",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config file or config string.")
|
||||
parser.add_argument("--hide", action="store_true", help="Hide on startup")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
elif args.gui_class == "BECDockArea":
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if args.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
if args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
elif args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
else:
|
||||
print(
|
||||
"Please specify a valid gui_class to run. Use -h for help."
|
||||
@@ -213,8 +281,10 @@ def main():
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
app.setApplicationName("BEC Figure")
|
||||
# set close on last window, only if not under control of client ;
|
||||
# indeed, Qt considers a hidden window a closed window, so if all windows
|
||||
# are hidden by default it exits
|
||||
app.setQuitOnLastWindowClosed(not args.hide)
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
@@ -222,18 +292,19 @@ def main():
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
# store gui id within QApplication object, to make it available to all widgets
|
||||
app.gui_id = args.id
|
||||
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
# args.id = "abff6"
|
||||
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
|
||||
|
||||
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
|
||||
win.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
win.setWindowTitle("BEC")
|
||||
|
||||
RPCRegister().add_rpc(win)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
if not args.hide:
|
||||
win.show()
|
||||
|
||||
@@ -242,6 +313,12 @@ def main():
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# first hide all top level windows
|
||||
# this is to discriminate the cases between "user clicks on [X]"
|
||||
# (which should be filtered, to not close -see BECDockArea-)
|
||||
# or "app is asked to close"
|
||||
for window in app.topLevelWidgets():
|
||||
window.hide() # so, we know we can exit because it is hidden
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
@@ -250,6 +327,5 @@ def main():
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -7,6 +7,7 @@ from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
@@ -17,7 +18,10 @@ from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@@ -48,13 +52,19 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"w10": self.w10,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"wave": self.wf,
|
||||
# "bar": self.bar,
|
||||
# "cm": self.colormap,
|
||||
"im": self.im,
|
||||
"mm": self.mm,
|
||||
"mw": self.mw,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
"wf": self.wf,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -79,11 +89,43 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
second_tab_layout.addWidget(self.figure)
|
||||
tab_widget.addTab(second_tab, "BEC Figure")
|
||||
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
fourth_tab = QWidget()
|
||||
fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
@@ -92,16 +134,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_waveform(self):
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
|
||||
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
|
||||
|
||||
def _init_figure(self):
|
||||
self.w1 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="bpm4i",
|
||||
# title="Standard Plot with sync device, custom labels - w1",
|
||||
# x_label="Motor Position",
|
||||
# y_label="Intensity (A.U.)",
|
||||
row=0,
|
||||
col=0,
|
||||
)
|
||||
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
|
||||
self.w1.set(
|
||||
title="Standard Plot with sync device, custom labels - w1",
|
||||
x_label="Motor Position",
|
||||
@@ -157,26 +198,19 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.mm = self.d0.add_widget("BECMotorMapWidget")
|
||||
self.d0 = self.dock.new(name="dock_0")
|
||||
self.mm = self.d0.new("BECMotorMapWidget")
|
||||
self.mm.change_motors("samx", "samy")
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.im = self.d1.add_widget("BECImageWidget")
|
||||
self.d1 = self.dock.new(name="dock_1", position="right")
|
||||
self.im = self.d1.new("BECImageWidget")
|
||||
self.im.image("waveform", "1d")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.d2 = self.dock.new(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.new("BECFigure", row=0, col=0)
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
# self.wf.plot(x_name="samx", y_name="bpm3a")
|
||||
# self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
# self.bar.set_diameter(200)
|
||||
|
||||
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
|
||||
# self.colormap = pg.GradientWidget()
|
||||
# self.d3.add_widget(self.colormap, row=0, col=0)
|
||||
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
@@ -202,7 +236,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
apply_theme("dark")
|
||||
icon = material_icon("terminal", color="#434343", filled=True)
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
|
||||
380
bec_widgets/qt_utils/collapsible_panel_manager.py
Normal file
380
bec_widgets/qt_utils/collapsible_panel_manager.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property, QEasingCurve, QObject, QPropertyAnimation
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
|
||||
|
||||
class DimensionAnimator(QObject):
|
||||
"""
|
||||
Helper class to animate the size of a panel widget.
|
||||
"""
|
||||
|
||||
def __init__(self, panel_widget: QWidget, direction: str):
|
||||
super().__init__()
|
||||
self.panel_widget = panel_widget
|
||||
self.direction = direction
|
||||
self._size = 0
|
||||
|
||||
@Property(int)
|
||||
def panel_width(self):
|
||||
"""
|
||||
Returns the current width of the panel widget.
|
||||
"""
|
||||
return self._size
|
||||
|
||||
@panel_width.setter
|
||||
def panel_width(self, val: int):
|
||||
"""
|
||||
Set the width of the panel widget.
|
||||
|
||||
Args:
|
||||
val(int): The width to set.
|
||||
"""
|
||||
self._size = val
|
||||
self.panel_widget.setFixedWidth(val)
|
||||
|
||||
@Property(int)
|
||||
def panel_height(self):
|
||||
"""
|
||||
Returns the current height of the panel widget.
|
||||
"""
|
||||
return self._size
|
||||
|
||||
@panel_height.setter
|
||||
def panel_height(self, val: int):
|
||||
"""
|
||||
Set the height of the panel widget.
|
||||
|
||||
Args:
|
||||
val(int): The height to set.
|
||||
"""
|
||||
self._size = val
|
||||
self.panel_widget.setFixedHeight(val)
|
||||
|
||||
|
||||
class CollapsiblePanelManager(QObject):
|
||||
"""
|
||||
Manager class to handle collapsible panels from a main widget using LayoutManagerWidget.
|
||||
"""
|
||||
|
||||
def __init__(self, layout_manager: LayoutManagerWidget, reference_widget: QWidget, parent=None):
|
||||
super().__init__(parent)
|
||||
self.layout_manager = layout_manager
|
||||
self.reference_widget = reference_widget
|
||||
self.animations = {}
|
||||
self.panels = {}
|
||||
self.direction_settings = {
|
||||
"left": {"property": b"maximumWidth", "default_size": 200},
|
||||
"right": {"property": b"maximumWidth", "default_size": 200},
|
||||
"top": {"property": b"maximumHeight", "default_size": 150},
|
||||
"bottom": {"property": b"maximumHeight", "default_size": 150},
|
||||
}
|
||||
|
||||
def add_panel(
|
||||
self,
|
||||
direction: Literal["left", "right", "top", "bottom"],
|
||||
panel_widget: QWidget,
|
||||
target_size: int | None = None,
|
||||
duration: int = 300,
|
||||
):
|
||||
"""
|
||||
Add a panel widget to the layout manager.
|
||||
|
||||
Args:
|
||||
direction(Literal["left", "right", "top", "bottom"]): Direction of the panel.
|
||||
panel_widget(QWidget): The panel widget to add.
|
||||
target_size(int, optional): The target size of the panel. Defaults to None.
|
||||
duration(int): The duration of the animation in milliseconds. Defaults to 300.
|
||||
"""
|
||||
if direction not in self.direction_settings:
|
||||
raise ValueError("Direction must be one of 'left', 'right', 'top', 'bottom'.")
|
||||
|
||||
if target_size is None:
|
||||
target_size = self.direction_settings[direction]["default_size"]
|
||||
|
||||
self.layout_manager.add_widget_relative(
|
||||
widget=panel_widget, reference_widget=self.reference_widget, position=direction
|
||||
)
|
||||
panel_widget.setVisible(False)
|
||||
|
||||
# Set initial constraints as flexible
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMaximumWidth(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMaximumHeight(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.panels[direction] = {
|
||||
"widget": panel_widget,
|
||||
"direction": direction,
|
||||
"target_size": target_size,
|
||||
"duration": duration,
|
||||
"animator": None,
|
||||
}
|
||||
|
||||
def toggle_panel(
|
||||
self,
|
||||
direction: Literal["left", "right", "top", "bottom"],
|
||||
target_size: int | None = None,
|
||||
duration: int | None = None,
|
||||
easing_curve: QEasingCurve = QEasingCurve.InOutQuad,
|
||||
ensure_max: bool = False,
|
||||
scale: float | None = None,
|
||||
animation: bool = True,
|
||||
):
|
||||
"""
|
||||
Toggle the specified panel.
|
||||
|
||||
Parameters:
|
||||
direction (Literal["left", "right", "top", "bottom"]): Direction of the panel to toggle.
|
||||
target_size (int, optional): Override target size for this toggle.
|
||||
duration (int, optional): Override the animation duration.
|
||||
easing_curve (QEasingCurve): Animation easing curve.
|
||||
ensure_max (bool): If True, animate as a fixed-size panel.
|
||||
scale (float, optional): If provided, calculate target_size from main widget size.
|
||||
animation (bool): If False, no animation is performed; panel instantly toggles.
|
||||
"""
|
||||
if direction not in self.panels:
|
||||
raise ValueError(f"No panel found in direction '{direction}'.")
|
||||
|
||||
panel_info = self.panels[direction]
|
||||
panel_widget = panel_info["widget"]
|
||||
dir_settings = self.direction_settings[direction]
|
||||
|
||||
# Determine final target size
|
||||
if scale is not None:
|
||||
main_rect = self.reference_widget.geometry()
|
||||
if direction in ["left", "right"]:
|
||||
computed_target = int(main_rect.width() * scale)
|
||||
else:
|
||||
computed_target = int(main_rect.height() * scale)
|
||||
final_target_size = computed_target
|
||||
else:
|
||||
if target_size is None:
|
||||
final_target_size = panel_info["target_size"]
|
||||
else:
|
||||
final_target_size = target_size
|
||||
|
||||
if duration is None:
|
||||
duration = panel_info["duration"]
|
||||
|
||||
expanding_property = dir_settings["property"]
|
||||
currently_visible = panel_widget.isVisible()
|
||||
|
||||
if ensure_max:
|
||||
if panel_info["animator"] is None:
|
||||
panel_info["animator"] = DimensionAnimator(panel_widget, direction)
|
||||
animator = panel_info["animator"]
|
||||
|
||||
if direction in ["left", "right"]:
|
||||
prop_name = b"panel_width"
|
||||
else:
|
||||
prop_name = b"panel_height"
|
||||
else:
|
||||
animator = None
|
||||
prop_name = expanding_property
|
||||
|
||||
if currently_visible:
|
||||
# Hide the panel
|
||||
if ensure_max:
|
||||
start_value = final_target_size
|
||||
end_value = 0
|
||||
finish_callback = lambda w=panel_widget, d=direction: self._after_hide_reset(w, d)
|
||||
else:
|
||||
start_value = (
|
||||
panel_widget.width()
|
||||
if direction in ["left", "right"]
|
||||
else panel_widget.height()
|
||||
)
|
||||
end_value = 0
|
||||
finish_callback = lambda w=panel_widget: w.setVisible(False)
|
||||
else:
|
||||
# Show the panel
|
||||
start_value = 0
|
||||
end_value = final_target_size
|
||||
finish_callback = None
|
||||
if ensure_max:
|
||||
# Fix panel exactly
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
else:
|
||||
# Flexible mode
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
panel_widget.setVisible(True)
|
||||
|
||||
if not animation:
|
||||
# No animation: instantly set final state
|
||||
if end_value == 0:
|
||||
# Hiding
|
||||
if ensure_max:
|
||||
# Reset after hide
|
||||
self._after_hide_reset(panel_widget, direction)
|
||||
else:
|
||||
panel_widget.setVisible(False)
|
||||
else:
|
||||
# Showing
|
||||
if ensure_max:
|
||||
# Already set fixed size
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setFixedWidth(end_value)
|
||||
else:
|
||||
panel_widget.setFixedHeight(end_value)
|
||||
else:
|
||||
# Just set maximum dimension
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMaximumWidth(end_value)
|
||||
else:
|
||||
panel_widget.setMaximumHeight(end_value)
|
||||
return
|
||||
|
||||
# With animation
|
||||
animation = QPropertyAnimation(animator if ensure_max else panel_widget, prop_name)
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(start_value)
|
||||
animation.setEndValue(end_value)
|
||||
animation.setEasingCurve(easing_curve)
|
||||
|
||||
if end_value == 0 and finish_callback:
|
||||
animation.finished.connect(finish_callback)
|
||||
elif end_value == 0 and not finish_callback:
|
||||
animation.finished.connect(lambda w=panel_widget: w.setVisible(False))
|
||||
|
||||
animation.start()
|
||||
self.animations[panel_widget] = animation
|
||||
|
||||
@typechecked
|
||||
def _after_hide_reset(
|
||||
self, panel_widget: QWidget, direction: Literal["left", "right", "top", "bottom"]
|
||||
):
|
||||
"""
|
||||
Reset the panel widget after hiding it in ensure_max mode.
|
||||
|
||||
Args:
|
||||
panel_widget(QWidget): The panel widget to reset.
|
||||
direction(Literal["left", "right", "top", "bottom"]): The direction of the panel.
|
||||
"""
|
||||
# Called after hiding a panel in ensure_max mode
|
||||
panel_widget.setVisible(False)
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(16777215)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# The following code is for the GUI control panel to interact with the CollapsiblePanelManager.
|
||||
# It is not covered by any tests as it serves only as an example for the CollapsiblePanelManager class.
|
||||
####################################################################################################
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Panels with ensure_max, scale, and animation toggle")
|
||||
self.resize(800, 600)
|
||||
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
main_layout = QVBoxLayout(central_widget)
|
||||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# Buttons
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.btn_left = QPushButton("Toggle Left (ensure_max=True)")
|
||||
self.btn_top = QPushButton("Toggle Top (scale=0.5, no animation)")
|
||||
self.btn_right = QPushButton("Toggle Right (ensure_max=True, scale=0.3)")
|
||||
self.btn_bottom = QPushButton("Toggle Bottom (no animation)")
|
||||
|
||||
buttons_layout.addWidget(self.btn_left)
|
||||
buttons_layout.addWidget(self.btn_top)
|
||||
buttons_layout.addWidget(self.btn_right)
|
||||
buttons_layout.addWidget(self.btn_bottom)
|
||||
|
||||
main_layout.addLayout(buttons_layout)
|
||||
|
||||
self.layout_manager = LayoutManagerWidget()
|
||||
main_layout.addWidget(self.layout_manager)
|
||||
|
||||
# Main widget
|
||||
self.main_plot = pg.PlotWidget()
|
||||
self.main_plot.plot([1, 2, 3, 4], [4, 3, 2, 1])
|
||||
self.layout_manager.add_widget(self.main_plot, 0, 0)
|
||||
|
||||
self.panel_manager = CollapsiblePanelManager(self.layout_manager, self.main_plot)
|
||||
|
||||
# Panels
|
||||
self.left_panel = pg.PlotWidget()
|
||||
self.left_panel.plot([1, 2, 3], [3, 2, 1])
|
||||
self.panel_manager.add_panel("left", self.left_panel, target_size=200)
|
||||
|
||||
self.right_panel = pg.PlotWidget()
|
||||
self.right_panel.plot([10, 20, 30], [1, 10, 1])
|
||||
self.panel_manager.add_panel("right", self.right_panel, target_size=200)
|
||||
|
||||
self.top_panel = pg.PlotWidget()
|
||||
self.top_panel.plot([1, 2, 3], [1, 2, 3])
|
||||
self.panel_manager.add_panel("top", self.top_panel, target_size=150)
|
||||
|
||||
self.bottom_panel = pg.PlotWidget()
|
||||
self.bottom_panel.plot([2, 4, 6], [10, 5, 10])
|
||||
self.panel_manager.add_panel("bottom", self.bottom_panel, target_size=150)
|
||||
|
||||
# Connect buttons
|
||||
# Left with ensure_max
|
||||
self.btn_left.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("left", ensure_max=True)
|
||||
)
|
||||
# Top with scale=0.5 and no animation
|
||||
self.btn_top.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("top", scale=0.5, animation=False)
|
||||
)
|
||||
# Right with ensure_max, scale=0.3
|
||||
self.btn_right.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("right", ensure_max=True, scale=0.3)
|
||||
)
|
||||
# Bottom no animation
|
||||
self.btn_bottom.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("bottom", target_size=100, animation=False)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -2,9 +2,93 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
"""
|
||||
Decorator to create a Qt Property with safe getter and setter so that
|
||||
Qt Designer won't crash if an exception occurs in either method.
|
||||
|
||||
Args:
|
||||
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
|
||||
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
|
||||
default: Any default/fallback value to return if the getter raises an exception.
|
||||
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
|
||||
|
||||
Usage:
|
||||
@SafeProperty(int, default=-1)
|
||||
def some_value(self) -> int:
|
||||
# your getter logic
|
||||
return ... # if an exception is raised, returns -1
|
||||
|
||||
@some_value.setter
|
||||
def some_value(self, val: int):
|
||||
# your setter logic
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(py_getter):
|
||||
"""Decorator for the user's property getter function."""
|
||||
|
||||
@functools.wraps(py_getter)
|
||||
def safe_getter(self_):
|
||||
try:
|
||||
return py_getter(self_)
|
||||
except Exception:
|
||||
# Identify which property function triggered error
|
||||
prop_name = f"{py_getter.__module__}.{py_getter.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
|
||||
return default
|
||||
|
||||
class PropertyWrapper:
|
||||
"""
|
||||
Intermediate wrapper used so that the user can optionally chain .setter(...).
|
||||
"""
|
||||
|
||||
def __init__(self, getter_func):
|
||||
# We store only our safe_getter in the wrapper
|
||||
self.getter_func = safe_getter
|
||||
|
||||
def setter(self, setter_func):
|
||||
"""Wraps the user-defined setter to handle errors safely."""
|
||||
|
||||
@functools.wraps(setter_func)
|
||||
def safe_setter(self_, value):
|
||||
try:
|
||||
return setter_func(self_, value)
|
||||
except Exception:
|
||||
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True
|
||||
)
|
||||
logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}")
|
||||
return
|
||||
|
||||
# Return the full read/write Property
|
||||
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
|
||||
|
||||
def __call__(self):
|
||||
"""
|
||||
If user never calls `.setter(...)`, produce a read-only property.
|
||||
"""
|
||||
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
|
||||
|
||||
return PropertyWrapper(py_getter)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
@@ -22,7 +106,13 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except Exception:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -91,6 +181,12 @@ class _ErrorPopupUtility(QObject):
|
||||
msg.setMinimumHeight(400)
|
||||
msg.exec_()
|
||||
|
||||
def show_property_error(self, title, message, widget):
|
||||
"""
|
||||
Show a property-specific error message.
|
||||
"""
|
||||
self.error_occurred.emit(title, message, widget)
|
||||
|
||||
def format_traceback(self, traceback_message: str) -> str:
|
||||
"""
|
||||
Format the traceback message to be displayed in the error popup by adding indentation to each line.
|
||||
@@ -127,12 +223,14 @@ class _ErrorPopupUtility(QObject):
|
||||
error_message = " ".join(captured_message)
|
||||
return error_message
|
||||
|
||||
def get_error_message(self, exctype, value, tb):
|
||||
return "".join(traceback.format_exception(exctype, value, tb))
|
||||
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
||||
if popup_error or self.enable_error_popup:
|
||||
error_message = traceback.format_exception(exctype, value, tb)
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
"".join(error_message),
|
||||
self.get_error_message(exctype, value, tb),
|
||||
self.parent(),
|
||||
)
|
||||
else:
|
||||
|
||||
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
COLLAPSED_ICON_NAME: str = "expand_all"
|
||||
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_icon()
|
||||
self._title = QLabel(f"<b>{title}</b>")
|
||||
self._title_layout.addWidget(self._expansion_button)
|
||||
self._title_layout.addWidget(self._title)
|
||||
|
||||
self._contents = QWidget()
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_icon()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, expanded: bool):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
|
||||
def _update_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
else material_icon(
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
157
bec_widgets/qt_utils/round_frame.py
Normal file
157
bec_widgets/qt_utils/round_frame.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(BECWidget, QFrame):
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
content_widget: QWidget = None,
|
||||
background_color: str = None,
|
||||
theme_update: bool = True,
|
||||
radius: int = 10,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
QFrame.__init__(self, parent)
|
||||
|
||||
self.background_color = background_color
|
||||
self.theme_update = theme_update if background_color is None else False
|
||||
self._radius = radius
|
||||
|
||||
# Apply rounded frame styling
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Create a layout for the frame
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
|
||||
|
||||
# Add the content widget to the layout
|
||||
if content_widget:
|
||||
self.layout.addWidget(content_widget)
|
||||
|
||||
# Store reference to the content widget
|
||||
self.content_widget = content_widget
|
||||
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if not self.theme_update:
|
||||
return
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
else:
|
||||
self.background_color = "#141414" # Dark mode
|
||||
|
||||
self.update_style()
|
||||
|
||||
@Property(int)
|
||||
def radius(self):
|
||||
"""Radius of the rounded corners."""
|
||||
return self._radius
|
||||
|
||||
@radius.setter
|
||||
def radius(self, value: int):
|
||||
self._radius = value
|
||||
self.update_style()
|
||||
|
||||
def update_style(self):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
if self.background_color:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
background-color: {self.background_color};
|
||||
border-radius: {self._radius}; /* Rounded corners */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
"""
|
||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
||||
|
||||
Args:
|
||||
border (str): Border style (e.g., 'none', '1px solid red').
|
||||
"""
|
||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||
# Apply border style via stylesheet
|
||||
self.content_widget.setStyleSheet(
|
||||
f"""
|
||||
GraphicsLayoutWidget {{
|
||||
border: {border}; /* Explicitly set the border */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Rounded Plots Example")
|
||||
|
||||
# Main layout
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
dark_button = DarkModeButton()
|
||||
|
||||
# Create PlotWidgets
|
||||
plot1 = pg.GraphicsLayoutWidget()
|
||||
plot_item_1 = pg.PlotItem()
|
||||
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
|
||||
plot1.plot_item = plot_item_1
|
||||
|
||||
plot2 = pg.GraphicsLayoutWidget()
|
||||
plot_item_2 = pg.PlotItem()
|
||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
|
||||
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(dark_button)
|
||||
layout.addWidget(rounded_plot1)
|
||||
layout.addWidget(rounded_plot2)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
def change_theme():
|
||||
rounded_plot1.apply_theme("light")
|
||||
rounded_plot2.apply_theme("dark")
|
||||
|
||||
QTimer.singleShot(100, change_theme)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
|
||||
app.exec()
|
||||
@@ -1,6 +1,6 @@
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class SettingWidget(QWidget):
|
||||
@@ -20,14 +20,14 @@ class SettingWidget(QWidget):
|
||||
def set_target_widget(self, target_widget: QWidget):
|
||||
self.target_widget = target_widget
|
||||
|
||||
@Slot()
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Accepts the changes made in the settings widget and applies them to the target widget.
|
||||
"""
|
||||
pass
|
||||
|
||||
@Slot(dict)
|
||||
@SafeSlot(dict)
|
||||
def display_current_settings(self, config_dict: dict):
|
||||
"""
|
||||
Displays the current settings of the target widget in the settings widget.
|
||||
@@ -54,12 +54,13 @@ class SettingsDialog(QDialog):
|
||||
settings_widget: SettingWidget = None,
|
||||
window_title: str = "Settings",
|
||||
config: dict = None,
|
||||
modal: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self.setModal(False)
|
||||
self.setModal(modal)
|
||||
|
||||
self.setWindowTitle(window_title)
|
||||
|
||||
@@ -92,7 +93,7 @@ class SettingsDialog(QDialog):
|
||||
ok_button.setDefault(True)
|
||||
ok_button.setAutoDefault(True)
|
||||
|
||||
@Slot()
|
||||
@SafeSlot()
|
||||
def accept(self):
|
||||
"""
|
||||
Accept the changes made in the settings widget and close the dialog.
|
||||
@@ -100,7 +101,7 @@ class SettingsDialog(QDialog):
|
||||
self.widget.accept_changes()
|
||||
super().accept()
|
||||
|
||||
@Slot()
|
||||
@SafeSlot()
|
||||
def apply_changes(self):
|
||||
"""
|
||||
Apply the changes made in the settings widget without closing the dialog.
|
||||
|
||||
369
bec_widgets/qt_utils/side_panel.py
Normal file
369
bec_widgets/qt_utils/side_panel.py
Normal file
@@ -0,0 +1,369 @@
|
||||
import sys
|
||||
from typing import Literal, Optional
|
||||
|
||||
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
|
||||
class SidePanel(QWidget):
|
||||
"""
|
||||
Side panel widget that can be placed on the left, right, top, or bottom of the main widget.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
orientation: Literal["left", "right", "top", "bottom"] = "left",
|
||||
panel_max_width: int = 200,
|
||||
animation_duration: int = 200,
|
||||
animations_enabled: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("SidePanel")
|
||||
|
||||
self._orientation = orientation
|
||||
self._panel_max_width = panel_max_width
|
||||
self._animation_duration = animation_duration
|
||||
self._animations_enabled = animations_enabled
|
||||
|
||||
self._panel_width = 0
|
||||
self._panel_height = 0
|
||||
self.panel_visible = False
|
||||
self.current_action: Optional[QAction] = None
|
||||
self.current_index: Optional[int] = None
|
||||
self.switching_actions = False
|
||||
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""
|
||||
Initialize the UI elements.
|
||||
"""
|
||||
if self._orientation in ("left", "right"):
|
||||
self.main_layout = QHBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
self.container.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.container.layout.setSpacing(0)
|
||||
|
||||
self.stack_widget = QStackedWidget()
|
||||
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.stack_widget.setMinimumWidth(5)
|
||||
self.stack_widget.setMaximumWidth(self._panel_max_width)
|
||||
|
||||
if self._orientation == "left":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
self.menu_anim = QPropertyAnimation(self, b"panel_width")
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.panel_width = 0 # start hidden
|
||||
|
||||
else:
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
self.container.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.container.layout.setSpacing(0)
|
||||
|
||||
self.stack_widget = QStackedWidget()
|
||||
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.stack_widget.setMinimumHeight(5)
|
||||
self.stack_widget.setMaximumHeight(self._panel_max_width)
|
||||
|
||||
if self._orientation == "top":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
self.menu_anim = QPropertyAnimation(self, b"panel_height")
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.panel_height = 0 # start hidden
|
||||
|
||||
self.menu_anim.setDuration(self._animation_duration)
|
||||
self.menu_anim.setEasingCurve(QEasingCurve.InOutQuad)
|
||||
|
||||
@Property(int)
|
||||
def panel_width(self):
|
||||
"""Get the panel width."""
|
||||
return self._panel_width
|
||||
|
||||
@panel_width.setter
|
||||
def panel_width(self, width: int):
|
||||
"""Set the panel width."""
|
||||
self._panel_width = width
|
||||
if self._orientation in ("left", "right"):
|
||||
self.stack_widget.setFixedWidth(width)
|
||||
|
||||
@Property(int)
|
||||
def panel_height(self):
|
||||
"""Get the panel height."""
|
||||
return self._panel_height
|
||||
|
||||
@panel_height.setter
|
||||
def panel_height(self, height: int):
|
||||
"""Set the panel height."""
|
||||
self._panel_height = height
|
||||
if self._orientation in ("top", "bottom"):
|
||||
self.stack_widget.setFixedHeight(height)
|
||||
|
||||
@Property(int)
|
||||
def panel_max_width(self):
|
||||
"""Get the maximum width of the panel."""
|
||||
return self._panel_max_width
|
||||
|
||||
@panel_max_width.setter
|
||||
def panel_max_width(self, size: int):
|
||||
"""Set the maximum width of the panel."""
|
||||
self._panel_max_width = size
|
||||
if self._orientation in ("left", "right"):
|
||||
self.stack_widget.setMaximumWidth(self._panel_max_width)
|
||||
else:
|
||||
self.stack_widget.setMaximumHeight(self._panel_max_width)
|
||||
|
||||
@Property(int)
|
||||
def animation_duration(self):
|
||||
"""Get the duration of the animation."""
|
||||
return self._animation_duration
|
||||
|
||||
@animation_duration.setter
|
||||
def animation_duration(self, duration: int):
|
||||
"""Set the duration of the animation."""
|
||||
self._animation_duration = duration
|
||||
self.menu_anim.setDuration(duration)
|
||||
|
||||
@Property(bool)
|
||||
def animations_enabled(self):
|
||||
"""Get the status of the animations."""
|
||||
return self._animations_enabled
|
||||
|
||||
@animations_enabled.setter
|
||||
def animations_enabled(self, enabled: bool):
|
||||
"""Set the status of the animations."""
|
||||
self._animations_enabled = enabled
|
||||
|
||||
def show_panel(self, idx: int):
|
||||
"""
|
||||
Show the side panel with animation and switch to idx.
|
||||
"""
|
||||
self.stack_widget.setCurrentIndex(idx)
|
||||
self.panel_visible = True
|
||||
self.current_index = idx
|
||||
|
||||
if self._orientation in ("left", "right"):
|
||||
start_val, end_val = 0, self._panel_max_width
|
||||
else:
|
||||
start_val, end_val = 0, self._panel_max_width
|
||||
|
||||
if self._animations_enabled:
|
||||
self.menu_anim.stop()
|
||||
self.menu_anim.setStartValue(start_val)
|
||||
self.menu_anim.setEndValue(end_val)
|
||||
self.menu_anim.start()
|
||||
else:
|
||||
if self._orientation in ("left", "right"):
|
||||
self.panel_width = end_val
|
||||
else:
|
||||
self.panel_height = end_val
|
||||
|
||||
def hide_panel(self):
|
||||
"""
|
||||
Hide the side panel with animation.
|
||||
"""
|
||||
self.panel_visible = False
|
||||
self.current_index = None
|
||||
|
||||
if self._orientation in ("left", "right"):
|
||||
start_val, end_val = self._panel_max_width, 0
|
||||
else:
|
||||
start_val, end_val = self._panel_max_width, 0
|
||||
|
||||
if self._animations_enabled:
|
||||
self.menu_anim.stop()
|
||||
self.menu_anim.setStartValue(start_val)
|
||||
self.menu_anim.setEndValue(end_val)
|
||||
self.menu_anim.start()
|
||||
else:
|
||||
if self._orientation in ("left", "right"):
|
||||
self.panel_width = end_val
|
||||
else:
|
||||
self.panel_height = end_val
|
||||
|
||||
def switch_to(self, idx: int):
|
||||
"""
|
||||
Switch to the specified index without animation.
|
||||
"""
|
||||
if self.current_index != idx:
|
||||
self.stack_widget.setCurrentIndex(idx)
|
||||
self.current_index = idx
|
||||
|
||||
def add_menu(self, action_id: str, icon_name: str, tooltip: str, widget: QWidget, title: str):
|
||||
"""
|
||||
Add a menu to the side panel.
|
||||
|
||||
Args:
|
||||
action_id(str): The ID of the action.
|
||||
icon_name(str): The name of the icon.
|
||||
tooltip(str): The tooltip for the action.
|
||||
widget(QWidget): The widget to add to the panel.
|
||||
title(str): The title of the panel.
|
||||
"""
|
||||
# container_widget: top-level container for the stacked page
|
||||
container_widget = QWidget()
|
||||
container_layout = QVBoxLayout(container_widget)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(5)
|
||||
|
||||
title_label = QLabel(f"<b>{title}</b>")
|
||||
title_label.setStyleSheet("font-size: 16px;")
|
||||
container_layout.addWidget(title_label)
|
||||
|
||||
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
scroll_area.setWidgetResizable(True)
|
||||
# Let the scroll area expand in both directions if there's room
|
||||
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
scroll_area.setWidget(widget)
|
||||
|
||||
# Put the scroll area in the container layout
|
||||
container_layout.addWidget(scroll_area)
|
||||
|
||||
# Optionally stretch the scroll area to fill vertical space
|
||||
container_layout.setStretchFactor(scroll_area, 1)
|
||||
|
||||
# Add container_widget to the stacked widget
|
||||
index = self.stack_widget.count()
|
||||
self.stack_widget.addWidget(container_widget)
|
||||
|
||||
# Add an action to the toolbar
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
return
|
||||
|
||||
if checked:
|
||||
if self.current_action and self.current_action != action.action:
|
||||
self.switching_actions = True
|
||||
self.current_action.setChecked(False)
|
||||
self.switching_actions = False
|
||||
|
||||
self.current_action = action.action
|
||||
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
else:
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
|
||||
|
||||
############################################
|
||||
# DEMO APPLICATION
|
||||
############################################
|
||||
|
||||
|
||||
class ExampleApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Side Panel Example")
|
||||
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
self.layout = QHBoxLayout(central_widget)
|
||||
|
||||
# Create side panel
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
|
||||
self.layout.addWidget(self.side_panel)
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
self.plot = Waveform()
|
||||
self.layout.addWidget(self.plot)
|
||||
|
||||
self.add_side_menus()
|
||||
|
||||
def add_side_menus(self):
|
||||
widget1 = QWidget()
|
||||
layout1 = QVBoxLayout(widget1)
|
||||
for i in range(15):
|
||||
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
|
||||
self.side_panel.add_menu(
|
||||
action_id="widget1",
|
||||
icon_name="counter_1",
|
||||
tooltip="Show Widget 1",
|
||||
widget=widget1,
|
||||
title="Widget 1 Panel",
|
||||
)
|
||||
|
||||
widget2 = QWidget()
|
||||
layout2 = QVBoxLayout(widget2)
|
||||
layout2.addWidget(QLabel("Short widget 2 content"))
|
||||
self.side_panel.add_menu(
|
||||
action_id="widget2",
|
||||
icon_name="counter_2",
|
||||
tooltip="Show Widget 2",
|
||||
widget=widget2,
|
||||
title="Widget 2 Panel",
|
||||
)
|
||||
|
||||
widget3 = QWidget()
|
||||
layout3 = QVBoxLayout(widget3)
|
||||
for i in range(10):
|
||||
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
|
||||
self.side_panel.add_menu(
|
||||
action_id="widget3",
|
||||
icon_name="counter_3",
|
||||
tooltip="Show Widget 3",
|
||||
widget=widget3,
|
||||
title="Widget 3 Panel",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = ExampleApp()
|
||||
window.resize(1000, 700)
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -2,28 +2,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
from typing import Dict, List, Literal, Tuple
|
||||
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QToolBar,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
# Ensure that icons are shown in menus (especially on macOS)
|
||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
||||
|
||||
|
||||
class LongPressToolButton(QToolButton):
|
||||
def __init__(self, *args, long_press_threshold=500, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.long_press_threshold = long_press_threshold
|
||||
self._long_press_timer = QTimer(self)
|
||||
self._long_press_timer.setSingleShot(True)
|
||||
self._long_press_timer.timeout.connect(self.handleLongPress)
|
||||
self._pressed = False
|
||||
self._longPressed = False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._pressed = True
|
||||
self._longPressed = False
|
||||
self._long_press_timer.start(self.long_press_threshold)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._pressed = False
|
||||
if self._longPressed:
|
||||
self._longPressed = False
|
||||
self._long_press_timer.stop()
|
||||
event.accept() # Prevent normal click action after a long press
|
||||
return
|
||||
self._long_press_timer.stop()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def handleLongPress(self):
|
||||
if self._pressed:
|
||||
self._longPressed = True
|
||||
self.showMenu()
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""
|
||||
@@ -31,7 +73,7 @@ class ToolBarAction(ABC):
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
@@ -81,15 +123,33 @@ class IconAction(ToolBarAction):
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
|
||||
class MaterialIconAction:
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(self.icon, self.tooltip, parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
|
||||
|
||||
class MaterialIconAction(ToolBarAction):
|
||||
"""
|
||||
Action with a Material icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
||||
icon_name (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -99,30 +159,41 @@ class MaterialIconAction:
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = self.get_icon()
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
|
||||
icon = material_icon(
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
return icon
|
||||
self.action = QAction(self.icon, self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
Returns the icon for the action.
|
||||
|
||||
Returns:
|
||||
QIcon: The icon for the action.
|
||||
"""
|
||||
return self.icon
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
@@ -132,10 +203,9 @@ class DeviceSelectionAction(ToolBarAction):
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, device_combobox):
|
||||
def __init__(self, label: str | None = None, device_combobox=None):
|
||||
super().__init__()
|
||||
self.label = label
|
||||
self.device_combobox = device_combobox
|
||||
@@ -144,15 +214,101 @@ class DeviceSelectionAction(ToolBarAction):
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
label = QLabel(f"{self.label}")
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
if self.label is not None:
|
||||
label = QLabel(f"{self.label}")
|
||||
layout.addWidget(label)
|
||||
if self.device_combobox is not None:
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
def set_combobox_style(self, color: str):
|
||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
||||
|
||||
|
||||
class SwitchableToolBarAction(ToolBarAction):
|
||||
"""
|
||||
A split toolbar action that combines a main action and a drop-down menu for additional actions.
|
||||
|
||||
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
|
||||
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
|
||||
alternative action is selected, it becomes the new default and its callback is immediately executed.
|
||||
|
||||
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
|
||||
|
||||
Args:
|
||||
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
|
||||
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
|
||||
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to True.
|
||||
parent (QWidget, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actions: Dict[str, ToolBarAction],
|
||||
initial_action: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = True,
|
||||
default_state_checked: bool = False,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.actions = actions
|
||||
self.current_key = initial_action if initial_action is not None else next(iter(actions))
|
||||
self.parent = parent
|
||||
self.checkable = checkable
|
||||
self.default_state_checked = default_state_checked
|
||||
self.main_button = None
|
||||
self.menu_actions: Dict[str, QAction] = {}
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the split action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.main_button = LongPressToolButton(toolbar)
|
||||
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
|
||||
self.main_button.setCheckable(self.checkable)
|
||||
default_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(default_action.get_icon())
|
||||
self.main_button.setToolTip(default_action.tooltip)
|
||||
self.main_button.clicked.connect(self._trigger_current_action)
|
||||
menu = QMenu(self.main_button)
|
||||
self.menu_actions = {}
|
||||
for key, action_obj in self.actions.items():
|
||||
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setCheckable(self.checkable)
|
||||
menu_action.setChecked(key == self.current_key)
|
||||
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
|
||||
menu.addAction(menu_action)
|
||||
self.menu_actions[key] = menu_action
|
||||
self.main_button.setMenu(menu)
|
||||
toolbar.addWidget(self.main_button)
|
||||
|
||||
def _trigger_current_action(self):
|
||||
action_obj = self.actions[self.current_key]
|
||||
action_obj.action.trigger()
|
||||
|
||||
def set_default_action(self, key: str):
|
||||
self.current_key = key
|
||||
new_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(new_action.get_icon())
|
||||
self.main_button.setToolTip(new_action.tooltip)
|
||||
# Update check state of menu items
|
||||
for k, menu_act in self.menu_actions.items():
|
||||
menu_act.setChecked(k == key)
|
||||
new_action.action.trigger()
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
|
||||
|
||||
class WidgetAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding any widget to the toolbar.
|
||||
@@ -160,19 +316,26 @@ class WidgetAction(ToolBarAction):
|
||||
Args:
|
||||
label (str|None): The label for the widget.
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
|
||||
super().__init__(parent)
|
||||
super().__init__(icon_path=None, tooltip=label, checkable=False)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
self.container = None
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
container = QWidget()
|
||||
layout = QHBoxLayout(container)
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.container = QWidget()
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
layout.setSpacing(0)
|
||||
|
||||
if self.label is not None:
|
||||
label_widget = QLabel(f"{self.label}")
|
||||
@@ -193,19 +356,12 @@ class WidgetAction(ToolBarAction):
|
||||
|
||||
layout.addWidget(self.widget)
|
||||
|
||||
toolbar.addWidget(container)
|
||||
toolbar.addWidget(self.container)
|
||||
# Store the container as the action to allow toggling visibility.
|
||||
self.action = self.container
|
||||
|
||||
@staticmethod
|
||||
def calculate_minimum_width(combo_box: QComboBox) -> int:
|
||||
"""
|
||||
Calculate the minimum width required to display the longest item in the combo box.
|
||||
|
||||
Args:
|
||||
combo_box (QComboBox): The combo box to calculate the width for.
|
||||
|
||||
Returns:
|
||||
int: The calculated minimum width in pixels.
|
||||
"""
|
||||
font_metrics = combo_box.fontMetrics()
|
||||
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
|
||||
return max_width + 60
|
||||
@@ -219,7 +375,6 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
label (str): The label for the menu.
|
||||
actions (dict): A dictionary of actions to populate the menu.
|
||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||
@@ -246,12 +401,15 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
menu = QMenu(button)
|
||||
for action_id, action in self.actions.items():
|
||||
sub_action = QAction(action.tooltip, target)
|
||||
if hasattr(action, "icon_path"):
|
||||
sub_action.setIconVisibleInMenu(True)
|
||||
if action.icon_path:
|
||||
icon = QIcon()
|
||||
icon.addFile(action.icon_path, size=QSize(20, 20))
|
||||
sub_action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon"):
|
||||
sub_action.setIcon(action.get_icon())
|
||||
elif hasattr(action, "get_icon") and callable(action.get_icon):
|
||||
sub_icon = action.get_icon()
|
||||
if sub_icon and not sub_icon.isNull():
|
||||
sub_action.setIcon(sub_icon)
|
||||
sub_action.setCheckable(action.checkable)
|
||||
menu.addAction(sub_action)
|
||||
self.widgets[action_id] = sub_action
|
||||
@@ -259,38 +417,542 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
class ToolbarBundle:
|
||||
"""
|
||||
Represents a bundle of toolbar actions, keyed by action_id.
|
||||
Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
|
||||
def __init__(self, bundle_id: str = None, actions=None):
|
||||
"""
|
||||
Args:
|
||||
bundle_id (str): Unique identifier for the bundle.
|
||||
actions: Either None or a list of (action_id, ToolBarAction) tuples.
|
||||
"""
|
||||
self.bundle_id = bundle_id
|
||||
self._actions: dict[str, ToolBarAction] = {}
|
||||
|
||||
if actions is not None:
|
||||
for action_id, action in actions:
|
||||
self._actions[action_id] = action
|
||||
|
||||
def add_action(self, action_id: str, action: ToolBarAction):
|
||||
"""
|
||||
Adds or replaces an action in the bundle.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
self._actions[action_id] = action
|
||||
|
||||
def remove_action(self, action_id: str):
|
||||
"""
|
||||
Removes an action from the bundle by ID.
|
||||
Ignores if not present.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier for the action to remove.
|
||||
"""
|
||||
self._actions.pop(action_id, None)
|
||||
|
||||
@property
|
||||
def actions(self) -> dict[str, ToolBarAction]:
|
||||
"""
|
||||
Return the internal dictionary of actions so that you can do
|
||||
bundle.actions["drag_mode"] -> ToolBarAction instance.
|
||||
"""
|
||||
return self._actions
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
|
||||
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
actions: dict | None = None,
|
||||
target_widget=None,
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_color: str = "rgba(0, 0, 0, 0)",
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.widgets = defaultdict(dict)
|
||||
self.set_background_color()
|
||||
self.background_color = background_color
|
||||
self.set_background_color(self.background_color)
|
||||
|
||||
# Set the initial orientation
|
||||
self.set_orientation(orientation)
|
||||
|
||||
# Initialize bundles
|
||||
self.bundles = {}
|
||||
self.toolbar_items = []
|
||||
|
||||
if actions is not None and target_widget is not None:
|
||||
self.populate_toolbar(actions, target_widget)
|
||||
|
||||
def populate_toolbar(self, actions: dict, target_widget):
|
||||
def populate_toolbar(self, actions: dict, target_widget: QWidget):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
|
||||
actions (dict): A dictionary of action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
self.toolbar_items.clear() # Reset the order tracking
|
||||
for action_id, action in actions.items():
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
self.toolbar_items.append(("action", action_id))
|
||||
self.update_separators() # Ensure separators are updated after populating
|
||||
|
||||
def set_background_color(self):
|
||||
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
||||
"""
|
||||
Sets the background color and other appearance settings.
|
||||
|
||||
Args:
|
||||
color (str): The background color of the toolbar.
|
||||
"""
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")
|
||||
self.background_color = color
|
||||
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
|
||||
|
||||
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
|
||||
"""Sets the orientation of the toolbar.
|
||||
|
||||
Args:
|
||||
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
|
||||
"""
|
||||
if orientation == "horizontal":
|
||||
self.setOrientation(Qt.Horizontal)
|
||||
elif orientation == "vertical":
|
||||
self.setOrientation(Qt.Vertical)
|
||||
else:
|
||||
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
|
||||
|
||||
def update_material_icon_colors(self, new_color: str | tuple | QColor):
|
||||
"""
|
||||
Updates the color of all MaterialIconAction icons.
|
||||
|
||||
Args:
|
||||
new_color (str | tuple | QColor): The new color.
|
||||
"""
|
||||
for action in self.widgets.values():
|
||||
if isinstance(action, MaterialIconAction):
|
||||
action.color = new_color
|
||||
updated_icon = action.get_icon()
|
||||
action.action.setIcon(updated_icon)
|
||||
|
||||
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
|
||||
"""
|
||||
Adds a new standalone action dynamically.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
action (ToolBarAction): The action to add.
|
||||
target_widget (QWidget): The target widget.
|
||||
"""
|
||||
if action_id in self.widgets:
|
||||
raise ValueError(f"Action with ID '{action_id}' already exists.")
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
self.toolbar_items.append(("action", action_id))
|
||||
self.update_separators()
|
||||
|
||||
def hide_action(self, action_id: str):
|
||||
"""
|
||||
Hides a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
"""
|
||||
if action_id not in self.widgets:
|
||||
raise ValueError(f"Action with ID '{action_id}' does not exist.")
|
||||
action = self.widgets[action_id]
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(False)
|
||||
self.update_separators()
|
||||
|
||||
def show_action(self, action_id: str):
|
||||
"""
|
||||
Shows a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
"""
|
||||
if action_id not in self.widgets:
|
||||
raise ValueError(f"Action with ID '{action_id}' does not exist.")
|
||||
action = self.widgets[action_id]
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(True)
|
||||
self.update_separators()
|
||||
|
||||
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
|
||||
"""
|
||||
Adds a bundle of actions, separated by a separator.
|
||||
|
||||
Args:
|
||||
bundle (ToolbarBundle): The bundle.
|
||||
target_widget (QWidget): The target widget.
|
||||
"""
|
||||
if bundle.bundle_id in self.bundles:
|
||||
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
|
||||
|
||||
if self.toolbar_items:
|
||||
sep = SeparatorAction()
|
||||
sep.add_to_toolbar(self, target_widget)
|
||||
self.toolbar_items.append(("separator", None))
|
||||
|
||||
for action_id, action_obj in bundle.actions.items():
|
||||
action_obj.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action_obj
|
||||
|
||||
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
|
||||
self.toolbar_items.append(("bundle", bundle.bundle_id))
|
||||
self.update_separators()
|
||||
|
||||
def add_action_to_bundle(self, bundle_id: str, action_id: str, action, target_widget: QWidget):
|
||||
"""
|
||||
Dynamically adds an action to an existing bundle.
|
||||
|
||||
Args:
|
||||
bundle_id (str): The bundle ID.
|
||||
action_id (str): Unique identifier.
|
||||
action (ToolBarAction): The action to add.
|
||||
target_widget (QWidget): The target widget.
|
||||
"""
|
||||
if bundle_id not in self.bundles:
|
||||
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
|
||||
if action_id in self.widgets:
|
||||
raise ValueError(f"Action with ID '{action_id}' already exists.")
|
||||
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
new_qaction = action.action
|
||||
self.removeAction(new_qaction)
|
||||
|
||||
bundle_action_ids = self.bundles[bundle_id]
|
||||
if bundle_action_ids:
|
||||
last_bundle_action = self.widgets[bundle_action_ids[-1]].action
|
||||
actions_list = self.actions()
|
||||
try:
|
||||
index = actions_list.index(last_bundle_action)
|
||||
except ValueError:
|
||||
self.addAction(new_qaction)
|
||||
else:
|
||||
if index + 1 < len(actions_list):
|
||||
before_action = actions_list[index + 1]
|
||||
self.insertAction(before_action, new_qaction)
|
||||
else:
|
||||
self.addAction(new_qaction)
|
||||
else:
|
||||
self.addAction(new_qaction)
|
||||
|
||||
self.widgets[action_id] = action
|
||||
self.bundles[bundle_id].append(action_id)
|
||||
self.update_separators()
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""
|
||||
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
||||
|
||||
Args:
|
||||
event (QContextMenuEvent): The context menu event.
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
for item_type, identifier in self.toolbar_items:
|
||||
if item_type == "separator":
|
||||
menu.addSeparator()
|
||||
elif item_type == "bundle":
|
||||
self.handle_bundle_context_menu(menu, identifier)
|
||||
elif item_type == "action":
|
||||
self.handle_action_context_menu(menu, identifier)
|
||||
menu.triggered.connect(self.handle_menu_triggered)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
|
||||
"""
|
||||
Adds bundle actions to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu.
|
||||
bundle_id (str): The bundle identifier.
|
||||
"""
|
||||
action_ids = self.bundles.get(bundle_id, [])
|
||||
for act_id in action_ids:
|
||||
toolbar_action = self.widgets.get(act_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
|
||||
toolbar_action, "action"
|
||||
):
|
||||
continue
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
continue
|
||||
display_name = qaction.text() or toolbar_action.tooltip or act_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setData(act_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_action_context_menu(self, menu: QMenu, action_id: str):
|
||||
"""
|
||||
Adds a single toolbar action to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu to which the action is added.
|
||||
action_id (str): Unique identifier for the action.
|
||||
"""
|
||||
toolbar_action = self.widgets.get(action_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
|
||||
return
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
return
|
||||
display_name = qaction.text() or toolbar_action.tooltip or action_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setData(action_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_menu_triggered(self, action):
|
||||
"""
|
||||
Handles the triggered signal from the context menu.
|
||||
|
||||
Args:
|
||||
action: Action triggered.
|
||||
"""
|
||||
action_id = action.data()
|
||||
if action_id:
|
||||
self.toggle_action_visibility(action_id, action.isChecked())
|
||||
|
||||
def toggle_action_visibility(self, action_id: str, visible: bool):
|
||||
"""
|
||||
Toggles the visibility of a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
visible (bool): Whether the action should be visible.
|
||||
"""
|
||||
if action_id not in self.widgets:
|
||||
return
|
||||
tool_action = self.widgets[action_id]
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
tool_action.action.setVisible(visible)
|
||||
self.update_separators()
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
Hide separators that are adjacent to another separator or have no non-separator actions between them.
|
||||
"""
|
||||
toolbar_actions = self.actions()
|
||||
# First pass: set visibility based on surrounding non-separator actions.
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if not action.isSeparator():
|
||||
continue
|
||||
prev_visible = None
|
||||
for j in range(i - 1, -1, -1):
|
||||
if toolbar_actions[j].isVisible():
|
||||
prev_visible = toolbar_actions[j]
|
||||
break
|
||||
next_visible = None
|
||||
for j in range(i + 1, len(toolbar_actions)):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||
next_visible is None or next_visible.isSeparator()
|
||||
):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
action.setVisible(True)
|
||||
# Second pass: ensure no two visible separators are adjacent.
|
||||
prev = None
|
||||
for action in toolbar_actions:
|
||||
if action.isVisible() and action.isSeparator():
|
||||
if prev and prev.isSeparator():
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
if action.isVisible():
|
||||
prev = action
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
self.test_label = QLabel(text="This is a test label.")
|
||||
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
||||
self.central_widget.layout.addWidget(self.test_label)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
|
||||
self.add_switchable_button_checkable()
|
||||
self.add_switchable_button_non_checkable()
|
||||
self.add_widget_actions()
|
||||
self.add_bundles()
|
||||
self.add_menus()
|
||||
|
||||
# For theme testing
|
||||
|
||||
self.dark_button = DarkModeButton(toolbar=True)
|
||||
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
|
||||
self.toolbar.add_action("dark_mode", dark_mode_action, self)
|
||||
|
||||
def add_bundles(self):
|
||||
home_action = MaterialIconAction(
|
||||
icon_name="home", tooltip="Home", checkable=True, parent=self
|
||||
)
|
||||
settings_action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Settings", checkable=True, parent=self
|
||||
)
|
||||
profile_action = MaterialIconAction(
|
||||
icon_name="person", tooltip="Profile", checkable=True, parent=self
|
||||
)
|
||||
main_actions_bundle = ToolbarBundle(
|
||||
bundle_id="main_actions",
|
||||
actions=[
|
||||
("home_action", home_action),
|
||||
("settings_action", settings_action),
|
||||
("profile_action", profile_action),
|
||||
],
|
||||
)
|
||||
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
|
||||
|
||||
search_action = MaterialIconAction(
|
||||
icon_name="search", tooltip="Search", checkable=False, parent=self
|
||||
)
|
||||
help_action = MaterialIconAction(
|
||||
icon_name="help", tooltip="Help", checkable=False, parent=self
|
||||
)
|
||||
second_bundle = ToolbarBundle(
|
||||
bundle_id="secondary_actions",
|
||||
actions=[("search_action", search_action), ("help_action", help_action)],
|
||||
)
|
||||
self.toolbar.add_bundle(second_bundle, target_widget=self)
|
||||
|
||||
new_action = MaterialIconAction(
|
||||
icon_name="counter_1", tooltip="New Action", checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
"main_actions", "new_action", new_action, target_widget=self
|
||||
)
|
||||
|
||||
def add_menus(self):
|
||||
menu_material_actions = {
|
||||
"mat1": MaterialIconAction(
|
||||
icon_name="home", tooltip="Material Home", checkable=True, parent=self
|
||||
),
|
||||
"mat2": MaterialIconAction(
|
||||
icon_name="settings", tooltip="Material Settings", checkable=True, parent=self
|
||||
),
|
||||
"mat3": MaterialIconAction(
|
||||
icon_name="info", tooltip="Material Info", checkable=True, parent=self
|
||||
),
|
||||
}
|
||||
menu_qt_actions = {
|
||||
"qt1": QtIconAction(
|
||||
standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True, parent=self
|
||||
),
|
||||
"qt2": QtIconAction(
|
||||
standard_icon=QStyle.SP_DirIcon, tooltip="Qt Directory", checkable=True, parent=self
|
||||
),
|
||||
"qt3": QtIconAction(
|
||||
standard_icon=QStyle.SP_TrashIcon, tooltip="Qt Trash", checkable=True, parent=self
|
||||
),
|
||||
}
|
||||
expandable_menu_material = ExpandableMenuAction(
|
||||
label="Material Menu", actions=menu_material_actions
|
||||
)
|
||||
expandable_menu_qt = ExpandableMenuAction(label="Qt Menu", actions=menu_qt_actions)
|
||||
|
||||
self.toolbar.add_action("material_menu", expandable_menu_material, self)
|
||||
self.toolbar.add_action("qt_menu", expandable_menu_qt, self)
|
||||
|
||||
def add_switchable_button_checkable(self):
|
||||
action1 = MaterialIconAction(
|
||||
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
|
||||
)
|
||||
action2 = MaterialIconAction(
|
||||
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
|
||||
)
|
||||
|
||||
switchable_action = SwitchableToolBarAction(
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action", switchable_action, self)
|
||||
|
||||
action1.action.toggled.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
)
|
||||
action2.action.toggled.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
|
||||
)
|
||||
|
||||
def add_switchable_button_non_checkable(self):
|
||||
action1 = MaterialIconAction(
|
||||
icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self
|
||||
)
|
||||
action2 = MaterialIconAction(
|
||||
icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self
|
||||
)
|
||||
|
||||
switchable_action = SwitchableToolBarAction(
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
|
||||
|
||||
action1.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
)
|
||||
action2.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
|
||||
)
|
||||
switchable_action.actions["action1"].action.setChecked(True)
|
||||
|
||||
def add_widget_actions(self):
|
||||
combo = QComboBox()
|
||||
combo.addItems(["Option 1", "Option 2", "Option 3"])
|
||||
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -224,3 +224,11 @@ DEVICES = [
|
||||
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
|
||||
|
||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
@@ -12,11 +13,15 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -36,8 +41,7 @@ class ConnectionConfig(BaseModel):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
|
||||
return v
|
||||
|
||||
|
||||
@@ -72,7 +76,13 @@ class BECConnector:
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
name: str | None = None,
|
||||
):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
@@ -100,15 +110,22 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# I feel that we should not allow BECConnector to be created with a custom gui_id
|
||||
# because this would break with the logic in the RPCRegister of retrieving widgets by type
|
||||
# iterating over all widgets and checkinf if the register widget starts with the string that is passsed.
|
||||
# If the gui_id is randomly generated, this would break since that widget would have a
|
||||
# gui_id that is generated in a different way.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
self.gui_id: str = gui_id
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
# be careful: when registering, and the object is not a BECWidget,
|
||||
# cleanup has to called manually since there is no 'closeEvent'
|
||||
self.gui_id: str = self.config.gui_id # type: ignore
|
||||
if name is None:
|
||||
name = self.__class__.__name__
|
||||
else:
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(f"Name {name} contains invalid characters.")
|
||||
self._name = name if name else self.__class__.__name__
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
@@ -116,6 +133,8 @@ class BECConnector:
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
@@ -144,11 +163,14 @@ class BECConnector:
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
# Keep a reference to the worker so it is not garbage collected.
|
||||
self._workers.append(worker)
|
||||
# When the worker is done, remove it from our list.
|
||||
worker.signals.completed.connect(lambda: self._workers.remove(worker))
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
@@ -180,37 +202,39 @@ class BECConnector:
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
Set the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
Args:
|
||||
config (BaseModel): The new configuration model.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config(dict): Configuration settings.
|
||||
generate_new_id(bool): If True, generate a new GUI ID for the widget.
|
||||
config (dict): Configuration settings.
|
||||
generate_new_id (bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.set_gui_id(gui_id)
|
||||
self._set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to load the configuration file.
|
||||
path (str | None): Path to the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
@@ -229,8 +253,8 @@ class BECConnector:
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to save the configuration file.
|
||||
path (str | None): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
@@ -238,16 +262,15 @@ class BECConnector:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
# @pyqtSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
Args:
|
||||
gui_id(str): GUI ID
|
||||
gui_id (str): GUI ID.
|
||||
"""
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
@@ -268,7 +291,7 @@ class BECConnector:
|
||||
"""Update the client and device manager from BEC and create object for BEC shortcuts.
|
||||
|
||||
Args:
|
||||
client: BEC client
|
||||
client: BEC client.
|
||||
"""
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
@@ -279,25 +302,68 @@ class BECConnector:
|
||||
Update the configuration for the widget.
|
||||
|
||||
Args:
|
||||
config(ConnectionConfig): Configuration settings.
|
||||
config (ConnectionConfig | dict): Configuration settings.
|
||||
"""
|
||||
gui_id = getattr(config, "gui_id", None)
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
# TODO add error handler
|
||||
|
||||
self.config = config
|
||||
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
|
||||
self.config.gui_id = gui_id
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
if hasattr(self, "close"):
|
||||
self.close()
|
||||
if hasattr(self, "deleteLater"):
|
||||
self.deleteLater()
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Args:
|
||||
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
|
||||
dict_output (bool): If True, return the configuration as a dictionary.
|
||||
If False, return the configuration as a pydantic model.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the plot widget.
|
||||
dict | BaseModel: The configuration of the widget.
|
||||
"""
|
||||
if dict_output:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
|
||||
# --- Example usage of BECConnector: running a simple task ---
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
# Create a QApplication instance (required for QThreadPool)
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
connector = BECConnector()
|
||||
|
||||
def print_numbers():
|
||||
"""
|
||||
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
|
||||
"""
|
||||
for i in range(1, 11):
|
||||
print(i)
|
||||
time.sleep(0.5)
|
||||
|
||||
def task_complete():
|
||||
"""
|
||||
Called when the task is complete.
|
||||
"""
|
||||
print("Task complete")
|
||||
# Exit the application after the task completes.
|
||||
app.quit()
|
||||
|
||||
# Submit the task using the connector's submit_task method.
|
||||
connector.submit_task(print_numbers, on_complete=task_complete)
|
||||
|
||||
# Start the Qt event loop.
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -93,17 +93,24 @@ def patch_designer(): # pragma: no cover
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
current_pid = os.getpid()
|
||||
with open(f"/proc/{current_pid}/maps", "rt") as f:
|
||||
for line in f:
|
||||
if "libpython" in line:
|
||||
lib_path = line.split()[-1]
|
||||
os.environ[env_var] = lib_path
|
||||
break
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
|
||||
@@ -5,28 +5,43 @@ analyse data. Requesting a new fit may lead to request piling up and an overall
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class BECSignalProxy(SignalProxy):
|
||||
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
|
||||
"""
|
||||
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
|
||||
but arguments still being stored.
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to the SignalProxy class
|
||||
rateLimit (int): The rateLimit of the proxy
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class
|
||||
*args: Arguments to pass to the SignalProxy class.
|
||||
rateLimit (int): The rateLimit of the proxy.
|
||||
timeout (float): The number of seconds after which the proxy automatically
|
||||
unblocks if still blocked. Default is 10.0 seconds.
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class.
|
||||
|
||||
Example:
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)"""
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
|
||||
"""
|
||||
|
||||
is_blocked = Signal(bool)
|
||||
|
||||
def __init__(self, *args, rateLimit=25, **kwargs):
|
||||
def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
|
||||
super().__init__(*args, rateLimit=rateLimit, **kwargs)
|
||||
self._blocking = False
|
||||
self.old_args = None
|
||||
self.new_args = None
|
||||
|
||||
# Store timeout value (in seconds)
|
||||
self._timeout = timeout
|
||||
|
||||
# Create a single-shot timer for auto-unblocking
|
||||
self._timer = QTimer()
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout_unblock)
|
||||
|
||||
@property
|
||||
def blocked(self):
|
||||
"""Returns if the proxy is blocked"""
|
||||
@@ -46,9 +61,30 @@ class BECSignalProxy(SignalProxy):
|
||||
self.old_args = args
|
||||
super().signalReceived(*args)
|
||||
|
||||
@Slot()
|
||||
self._timer.start(int(self._timeout * 1000))
|
||||
|
||||
@SafeSlot()
|
||||
def unblock_proxy(self):
|
||||
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
if self.blocked:
|
||||
self._timer.stop()
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
|
||||
@SafeSlot()
|
||||
def _timeout_unblock(self):
|
||||
"""
|
||||
Internal method called by the QTimer upon timeout. Unblocks the proxy
|
||||
automatically if it is still blocked.
|
||||
"""
|
||||
if self.blocked:
|
||||
self.unblock_proxy()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._timer.timeout.disconnect(self._timeout_unblock)
|
||||
self._timer.deleteLater()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Slot
|
||||
@@ -7,6 +9,10 @@ from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -17,13 +23,18 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
name: str | None = None,
|
||||
parent_dock: BECDock | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
|
||||
@@ -44,9 +55,15 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
# Set the theme to auto if it is not set yet
|
||||
# Create a default name if None is provided
|
||||
if name is None:
|
||||
name = "bec_widget_init_without_name"
|
||||
# name = self.__class__.__name__
|
||||
# Check for invalid chars in the name
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(f"Name {name} contains invalid characters.")
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, name=name)
|
||||
self._parent_dock = parent_dock
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
@@ -66,7 +83,7 @@ class BECWidget(BECConnector):
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
|
||||
def _update_theme(self, theme: str):
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
qapp = QApplication.instance()
|
||||
@@ -87,10 +104,13 @@ class BECWidget(BECConnector):
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
# needed here instead of closeEvent, to be checked why
|
||||
# However, all widgets need to call super().cleanup() in their cleanup method
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.rpc_register.remove_rpc(self)
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
@@ -71,15 +70,64 @@ def apply_theme(theme: Literal["dark", "light"]):
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# go through all pyqtgraph widgets and set background
|
||||
children = itertools.chain.from_iterable(
|
||||
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
|
||||
)
|
||||
pg.setConfigOptions(
|
||||
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
|
||||
)
|
||||
for pg_widget in children:
|
||||
pg_widget.setBackground("k" if theme == "dark" else "w")
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Type
|
||||
from typing import Literal, Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
|
||||
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
|
||||
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
|
||||
# or alternatively raise an error that it can't be added again ( just raise an error)
|
||||
# 2. Dock names in between docks should also be unique
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
|
||||
"""
|
||||
Generate a unique widget ID.
|
||||
def has_name_valid_chars(name: str) -> bool:
|
||||
"""Check if the name is valid.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
prefix(str): The prefix of the widget ID.
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
widget_id(str): The unique widget ID.
|
||||
bool: True if the name is valid, False otherwise.
|
||||
"""
|
||||
existing_ids = set(container.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"{prefix}_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
if not name or len(name) > 256:
|
||||
return False # Don't accept empty names or names longer than 256 characters
|
||||
check_value = name.replace("_", "").replace("-", "")
|
||||
if not check_value.isalnum() or not check_value.isascii():
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
|
||||
"""Generate a unique ID.
|
||||
|
||||
Args:
|
||||
name(str): The name of the widget.
|
||||
Returns:
|
||||
tuple (str): The unique name
|
||||
"""
|
||||
if list_of_names is None:
|
||||
list_of_names = []
|
||||
ii = 0
|
||||
while ii < 1000: # 1000 is arbritrary!
|
||||
name_candidate = f"{name}_{ii}"
|
||||
if name_candidate not in list_of_names:
|
||||
return name_candidate
|
||||
ii += 1
|
||||
raise ValueError("Could not generate a unique name after within 1000 attempts.")
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
|
||||
@@ -22,7 +22,9 @@ class EntryValidator:
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -148,10 +148,7 @@ class BECTickItem(BECIndicatorItem):
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
if self.tick_item is not None:
|
||||
self.tick_item.close()
|
||||
self.tick_item.deleteLater()
|
||||
self.tick_item = None
|
||||
self.tick_item = None
|
||||
|
||||
|
||||
class BECArrowItem(BECIndicatorItem):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -16,6 +15,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
@@ -28,6 +29,15 @@ class WidgetHandler(ABC):
|
||||
def set_value(self, widget: QWidget, value):
|
||||
"""Set a value on the widget instance."""
|
||||
|
||||
def connect_change_signal(self, widget: QWidget, slot):
|
||||
"""
|
||||
Connect a change signal from this widget to the given slot.
|
||||
If the widget type doesn't have a known "value changed" signal, do nothing.
|
||||
|
||||
slot: a function accepting two arguments (widget, value)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LineEditHandler(WidgetHandler):
|
||||
"""Handler for QLineEdit widgets."""
|
||||
@@ -38,6 +48,9 @@ class LineEditHandler(WidgetHandler):
|
||||
def set_value(self, widget: QLineEdit, value: str) -> None:
|
||||
widget.setText(value)
|
||||
|
||||
def connect_change_signal(self, widget: QLineEdit, slot):
|
||||
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
|
||||
|
||||
|
||||
class ComboBoxHandler(WidgetHandler):
|
||||
"""Handler for QComboBox widgets."""
|
||||
@@ -53,6 +66,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
def connect_change_signal(self, widget: QComboBox, slot):
|
||||
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
|
||||
# We use currentIndexChanged(int) for a consistent behavior.
|
||||
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
@@ -72,6 +90,16 @@ class TableWidgetHandler(WidgetHandler):
|
||||
item = QTableWidgetItem(str(cell_value))
|
||||
widget.setItem(row, col, item)
|
||||
|
||||
def connect_change_signal(self, widget: QTableWidget, slot):
|
||||
# If desired, we could connect cellChanged(row, col) and then fetch all data.
|
||||
# This might be noisy if table is large.
|
||||
# For demonstration, connect cellChanged to update entire table value.
|
||||
def on_cell_changed(row, col, w=widget):
|
||||
val = self.get_value(w)
|
||||
slot(w, val)
|
||||
|
||||
widget.cellChanged.connect(on_cell_changed)
|
||||
|
||||
|
||||
class SpinBoxHandler(WidgetHandler):
|
||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||
@@ -82,6 +110,9 @@ class SpinBoxHandler(WidgetHandler):
|
||||
def set_value(self, widget, value):
|
||||
widget.setValue(value)
|
||||
|
||||
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
|
||||
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class CheckBoxHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
@@ -92,6 +123,22 @@ class CheckBoxHandler(WidgetHandler):
|
||||
def set_value(self, widget, value):
|
||||
widget.setChecked(value)
|
||||
|
||||
def connect_change_signal(self, widget: QCheckBox, slot):
|
||||
widget.toggled.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class ToggleSwitchHandler(WidgetHandler):
|
||||
"""Handler for ToggleSwitch widgets."""
|
||||
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.checked
|
||||
|
||||
def set_value(self, widget, value):
|
||||
widget.checked = value
|
||||
|
||||
def connect_change_signal(self, widget: ToggleSwitch, slot):
|
||||
widget.enabled.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class LabelHandler(WidgetHandler):
|
||||
"""Handler for QLabel widgets."""
|
||||
@@ -99,12 +146,15 @@ class LabelHandler(WidgetHandler):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.text()
|
||||
|
||||
def set_value(self, widget, value):
|
||||
def set_value(self, widget: QLabel, value):
|
||||
widget.setText(value)
|
||||
|
||||
# QLabel typically doesn't have user-editable changes. No signal to connect.
|
||||
# If needed, this can remain empty.
|
||||
|
||||
|
||||
class WidgetIO:
|
||||
"""Public interface for getting and setting values using handler mapping"""
|
||||
"""Public interface for getting, setting values and connecting signals using handler mapping"""
|
||||
|
||||
_handlers = {
|
||||
QLineEdit: LineEditHandler,
|
||||
@@ -114,6 +164,7 @@ class WidgetIO:
|
||||
QDoubleSpinBox: SpinBoxHandler,
|
||||
QCheckBox: CheckBoxHandler,
|
||||
QLabel: LabelHandler,
|
||||
ToggleSwitch: ToggleSwitchHandler,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -148,6 +199,17 @@ class WidgetIO:
|
||||
elif not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
@staticmethod
|
||||
def connect_widget_change_signal(widget, slot):
|
||||
"""
|
||||
Connect the widget's value-changed signal to a generic slot function (widget, value).
|
||||
This now delegates the logic to the widget's handler.
|
||||
"""
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
handler = handler_class()
|
||||
handler.connect_change_signal(widget, slot)
|
||||
|
||||
@staticmethod
|
||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||
"""
|
||||
@@ -309,8 +371,8 @@ class WidgetHierarchy:
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
|
||||
# Example application to demonstrate the usage of the functions
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Example usage
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
# Create instance of WidgetHierarchy
|
||||
@@ -365,3 +427,37 @@ if __name__ == "__main__": # pragma: no cover
|
||||
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
||||
|
||||
app.exec()
|
||||
|
||||
|
||||
def widget_io_signal_example(): # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
main_widget = QWidget()
|
||||
layout = QVBoxLayout(main_widget)
|
||||
line_edit = QLineEdit(main_widget)
|
||||
combo_box = QComboBox(main_widget)
|
||||
spin_box = QSpinBox(main_widget)
|
||||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||
|
||||
layout.addWidget(line_edit)
|
||||
layout.addWidget(combo_box)
|
||||
layout.addWidget(spin_box)
|
||||
|
||||
main_widget.show()
|
||||
|
||||
def universal_slot(w, val):
|
||||
print(f"Widget {w.objectName() or w} changed, new value: {val}")
|
||||
|
||||
# Connect all supported widgets through their handlers
|
||||
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||||
|
||||
app.exec_()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Change example function to test different scenarios
|
||||
|
||||
# hierarchy_example()
|
||||
widget_io_signal_example()
|
||||
|
||||
223
bec_widgets/utils/widget_state_manager.py
Normal file
223
bec_widgets/utils/widget_state_manager.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtCore import QSettings
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class WidgetStateManager:
|
||||
"""
|
||||
A class to manage the state of a widget by saving and loading the state to and from a INI file.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to manage the state for.
|
||||
"""
|
||||
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
|
||||
def save_state(self, filename: str = None):
|
||||
"""
|
||||
Save the state of the widget to an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to save the state to.
|
||||
"""
|
||||
if not filename:
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
|
||||
def load_state(self, filename: str = None):
|
||||
"""
|
||||
Load the state of the widget from an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to load the state from.
|
||||
"""
|
||||
if not filename:
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
|
||||
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
"""
|
||||
Save the state of the widget to QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to save the state for.
|
||||
settings(QSettings): The QSettings object to save the state to.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
|
||||
meta = widget.metaObject()
|
||||
widget_name = self._get_full_widget_name(widget)
|
||||
settings.beginGroup(widget_name)
|
||||
for i in range(meta.propertyCount()):
|
||||
prop = meta.property(i)
|
||||
name = prop.name()
|
||||
if (
|
||||
name == "objectName"
|
||||
or not prop.isReadable()
|
||||
or not prop.isWritable()
|
||||
or not prop.isStored() # can be extended to fine filter
|
||||
):
|
||||
continue
|
||||
value = widget.property(name)
|
||||
settings.setValue(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._save_widget_state_qsettings(child, settings)
|
||||
|
||||
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
"""
|
||||
Load the state of the widget from QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to load the state for.
|
||||
settings(QSettings): The QSettings object to load the state from.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
|
||||
meta = widget.metaObject()
|
||||
widget_name = self._get_full_widget_name(widget)
|
||||
settings.beginGroup(widget_name)
|
||||
for i in range(meta.propertyCount()):
|
||||
prop = meta.property(i)
|
||||
name = prop.name()
|
||||
if settings.contains(name):
|
||||
value = settings.value(name)
|
||||
widget.setProperty(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._load_widget_state_qsettings(child, settings)
|
||||
|
||||
def _get_full_widget_name(self, widget: QWidget):
|
||||
"""
|
||||
Get the full name of the widget including its parent names.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to get the full name for.
|
||||
|
||||
Returns:
|
||||
str: The full name of the widget.
|
||||
"""
|
||||
name = widget.objectName()
|
||||
parent = widget.parent()
|
||||
while parent:
|
||||
obj_name = parent.objectName() or parent.metaObject().className()
|
||||
name = obj_name + "." + name
|
||||
parent = parent.parent()
|
||||
return name
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("MainWindow")
|
||||
self.setWindowTitle("State Manager Example")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# A line edit to store some user text
|
||||
self.line_edit = QLineEdit(self)
|
||||
self.line_edit.setObjectName("MyLineEdit")
|
||||
self.line_edit.setPlaceholderText("Enter some text here...")
|
||||
layout.addWidget(self.line_edit)
|
||||
|
||||
# A spin box to hold a numeric value
|
||||
self.spin_box = QSpinBox(self)
|
||||
self.spin_box.setObjectName("MySpinBox")
|
||||
self.spin_box.setRange(0, 100)
|
||||
layout.addWidget(self.spin_box)
|
||||
|
||||
# A checkbox to hold a boolean value
|
||||
self.check_box = QCheckBox("Enable feature?", self)
|
||||
self.check_box.setObjectName("MyCheckBox")
|
||||
layout.addWidget(self.check_box)
|
||||
|
||||
# A checkbox that we want to skip
|
||||
self.check_box_skip = QCheckBox("Enable feature - skip save?", self)
|
||||
self.check_box_skip.setProperty("skip_state", True)
|
||||
self.check_box_skip.setObjectName("MyCheckBoxSkip")
|
||||
layout.addWidget(self.check_box_skip)
|
||||
|
||||
# CREATE A "SIDE PANEL" with nested structure and skip all what is inside
|
||||
self.side_panel = QWidget(self)
|
||||
self.side_panel.setObjectName("SidePanel")
|
||||
self.side_panel.setProperty("skip_settings", True) # skip the ENTIRE panel
|
||||
layout.addWidget(self.side_panel)
|
||||
|
||||
# Put some sub-widgets inside side_panel
|
||||
panel_layout = QVBoxLayout(self.side_panel)
|
||||
self.panel_label = QLabel("Label in side panel", self.side_panel)
|
||||
self.panel_label.setObjectName("PanelLabel")
|
||||
panel_layout.addWidget(self.panel_label)
|
||||
|
||||
self.panel_edit = QLineEdit(self.side_panel)
|
||||
self.panel_edit.setObjectName("PanelLineEdit")
|
||||
self.panel_edit.setPlaceholderText("I am inside side panel")
|
||||
panel_layout.addWidget(self.panel_edit)
|
||||
|
||||
self.panel_checkbox = QCheckBox("Enable feature in side panel?", self.side_panel)
|
||||
self.panel_checkbox.setObjectName("PanelCheckBox")
|
||||
panel_layout.addWidget(self.panel_checkbox)
|
||||
|
||||
# Save/Load buttons
|
||||
button_layout = QHBoxLayout()
|
||||
self.save_button = QPushButton("Save State", self)
|
||||
self.load_button = QPushButton("Load State", self)
|
||||
button_layout.addWidget(self.save_button)
|
||||
button_layout.addWidget(self.load_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Create the state manager
|
||||
self.state_manager = WidgetStateManager(self)
|
||||
|
||||
# Connect buttons
|
||||
self.save_button.clicked.connect(lambda: self.state_manager.save_state())
|
||||
self.load_button.clicked.connect(lambda: self.state_manager.load_state())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover:
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = ExampleApp()
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock, DockLabel
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
|
||||
"bottom", description="The position of the dock."
|
||||
)
|
||||
parent_dock_area: Optional[str] = Field(
|
||||
parent_dock_area: Optional[str] | None = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
@@ -103,16 +111,17 @@ class BECDock(BECWidget, Dock):
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"_rpc_id",
|
||||
"widget_list",
|
||||
"element_list",
|
||||
"elements",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"show_title_bar",
|
||||
"hide_title_bar",
|
||||
"get_widgets_positions",
|
||||
"set_title",
|
||||
"add_widget",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
"hide_title_bar",
|
||||
"available_widgets",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
@@ -121,7 +130,7 @@ class BECDock(BECWidget, Dock):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
client=None,
|
||||
@@ -129,21 +138,24 @@ class BECDock(BECWidget, Dock):
|
||||
closable: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
if config is None:
|
||||
config = DockConfig(
|
||||
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, name=name
|
||||
) # Name was checked and created in BEC Widget
|
||||
label = CustomDockLabel(text=name, closable=closable)
|
||||
Dock.__init__(self, name=name, label=label, **kwargs)
|
||||
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
|
||||
# Dock.__init__(self, name=name, **kwargs)
|
||||
|
||||
self.parent_dock_area = parent_dock_area
|
||||
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
@@ -173,7 +185,18 @@ class BECDock(BECWidget, Dock):
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECWidget]:
|
||||
def elements(self) -> dict[str, BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(dict): The widgets in the dock.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
return dict((widget._name, widget) for widget in self.element_list)
|
||||
|
||||
@property
|
||||
def element_list(self) -> list[BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
@@ -182,10 +205,6 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECWidget]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -194,6 +213,20 @@ class BECDock(BECWidget, Dock):
|
||||
self.label.hide()
|
||||
self.labelHidden = True
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Show the dock.
|
||||
"""
|
||||
super().show()
|
||||
self.show_title_bar()
|
||||
|
||||
def hide(self):
|
||||
"""
|
||||
Hide the dock.
|
||||
"""
|
||||
self.hide_title_bar()
|
||||
super().hide()
|
||||
|
||||
def show_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -211,7 +244,6 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
@@ -222,7 +254,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def list_eligible_widgets(
|
||||
def available_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
@@ -233,20 +265,29 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def add_widget(
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self):
|
||||
docks = self.parent_dock_area.panel_list
|
||||
widgets = []
|
||||
for dock in docks:
|
||||
widgets.extend(dock.elements.keys())
|
||||
return widgets
|
||||
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
name: str | None = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Add a widget to the dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add.
|
||||
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
|
||||
name(str): The name of the widget.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to.
|
||||
rowspan(int): The number of rows the widget should span.
|
||||
@@ -254,23 +295,51 @@ class BECDock(BECWidget, Dock):
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if row is None:
|
||||
# row = cast(int, self.layout.rowCount()) # type:ignore
|
||||
row = self.layout.rowCount()
|
||||
# row = cast(int, row)
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
|
||||
|
||||
if name is not None: # Name is provided
|
||||
if name in existing_widgets_parent_dock:
|
||||
# pylint: disable=protected-access
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for widgets, but already exists in DockArea "
|
||||
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
name = WidgetContainerUtils.generate_unique_name(
|
||||
name=widget_class_name, list_of_names=existing_widgets_parent_dock
|
||||
)
|
||||
# Check that Widget is not BECDock or BECDockArea
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
if widget_class_name in IGNORE_WIDGETS:
|
||||
raise ValueError(f"Widget {widget} can not be added to dock.")
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = widget_handler.create_widget(widget)
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(widget_type=widget, name=name, parent_dock=self),
|
||||
)
|
||||
else:
|
||||
widget = widget
|
||||
widget._name = name # pylint: disable=protected-access
|
||||
|
||||
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
|
||||
if hasattr(widget, "config"):
|
||||
self.config.widgets[widget.gui_id] = widget.config
|
||||
|
||||
widget.config.gui_id = widget.gui_id
|
||||
self.config.widgets[widget._name] = widget.config # pylint: disable=protected-access
|
||||
self._broadcast_update()
|
||||
return widget
|
||||
|
||||
def _broadcast_update(self):
|
||||
rpc_register = RPCRegister()
|
||||
rpc_register.broadcast()
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
@@ -294,37 +363,72 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove_widget(self, widget_rpc_id: str):
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_rpc_id(str): The ID of the widget to remove.
|
||||
"""
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
# self.cleanup()
|
||||
self.parent_dock_area.remove_dock(self.name())
|
||||
self.parent_dock_area.delete(self._name)
|
||||
|
||||
def delete(self, widget_name: str) -> None:
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_name(str): Delete the widget with the given name.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
widgets = [widget for widget in self.widgets if widget._name == widget_name]
|
||||
if len(widgets) == 0:
|
||||
logger.warning(
|
||||
f"Widget with name {widget_name} not found in dock {self.name()}. "
|
||||
f"Checking if gui_id was passed as widget_name."
|
||||
)
|
||||
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_name)
|
||||
if widget is None:
|
||||
logger.warning(
|
||||
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
widget = widgets[0]
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget._name, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
self._broadcast_update()
|
||||
|
||||
def delete_all(self):
|
||||
"""
|
||||
Remove all widgets from the dock.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
self.delete(widget._name) # pylint: disable=protected-access
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the dock, including all its widgets.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
# Remove the dock from the parent dock area
|
||||
if self.parent_dock_area:
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
self.parent_dock_area.config.docks.pop(self.name(), None)
|
||||
self.delete_all()
|
||||
self.widgets.clear()
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
# def closeEvent(self, event): # pylint: disable=uselsess-parent-delegation
|
||||
# """Close Event for dock and cleanup.
|
||||
|
||||
# This wrapper ensures that the BECWidget close event is triggered.
|
||||
# If removed, the closeEvent from pyqtgraph will be triggered, which
|
||||
# is not calling super().closeEvent(event) and will not trigger the BECWidget close event.
|
||||
# """
|
||||
# return super().closeEvent(event)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
@@ -332,4 +436,15 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
dock = BECDock(name="dock")
|
||||
dock.show()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -3,12 +3,15 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
ExpandableMenuAction,
|
||||
@@ -19,18 +22,21 @@ from bec_widgets.qt_utils.toolbar import (
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
@@ -42,19 +48,19 @@ class DockAreaConfig(ConnectionConfig):
|
||||
class BECDockArea(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"panels",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
"restore_state",
|
||||
"add_dock",
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"_get_all_rpc",
|
||||
"temp_areas",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"panels",
|
||||
"panel_list",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"selected_device",
|
||||
"save_state",
|
||||
"restore_state",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -63,6 +69,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
name: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
@@ -70,8 +78,9 @@ class BECDockArea(BECWidget, QWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self._parent = parent
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(5)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -85,9 +94,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=BECWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=BECMultiWaveformWidget.ICON_NAME,
|
||||
@@ -136,6 +143,9 @@ class BECDockArea(BECWidget, QWidget):
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
@@ -158,41 +168,47 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.addWidget(DarkModeButton(toolbar=True))
|
||||
self._hook_toolbar()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECMultiWaveformWidget")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECImageWidget")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECMotorMapWidget")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECQueue", prefix="queue")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
)
|
||||
|
||||
# Icons
|
||||
@@ -200,6 +216,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
|
||||
self.new(name=dock_name, widget=widget_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
|
||||
super().paintEvent(event)
|
||||
if self._instructions_visible:
|
||||
@@ -207,9 +228,20 @@ class BECDockArea(BECWidget, QWidget):
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignCenter,
|
||||
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
def selected_device(self) -> str:
|
||||
gui_id = QApplication.instance().gui_id
|
||||
auto_update_config = self.client.connector.get(
|
||||
MessageEndpoints.gui_auto_update_config(gui_id)
|
||||
)
|
||||
try:
|
||||
return auto_update_config.selected_device
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
@@ -221,7 +253,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict[str, BECDock]):
|
||||
self.dock_area.docks = WeakValueDictionary(value)
|
||||
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
|
||||
|
||||
@property
|
||||
def panel_list(self) -> list[BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The docks in the dock area.
|
||||
"""
|
||||
return list(self.dock_area.docks.values())
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
@@ -265,36 +307,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
Remove a dock by name and ensure it is properly closed and cleaned up.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(name, None)
|
||||
self.config.docks.pop(name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Dock with name {name} does not exist.")
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def add_dock(
|
||||
def new(
|
||||
self,
|
||||
name: str = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
name: str | None = None,
|
||||
widget: str | QWidget | None = None,
|
||||
widget_name: str | None = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = True,
|
||||
floating: bool = False,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
@@ -304,12 +327,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to be displayed and for further references. Has to be unique.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
|
||||
relative_to(BECDock): The dock to which the new dock should be added relative to.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floating(bool): Whether the dock is detached after creating.
|
||||
prefix(str): The prefix for the dock name if no name is provided.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
row(int): The row of the added widget.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
@@ -318,21 +340,20 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
if name is None:
|
||||
name = WidgetContainerUtils.generate_unique_widget_id(
|
||||
container=self.dock_area.docks, prefix=prefix
|
||||
)
|
||||
|
||||
if name in set(self.dock_area.docks.keys()):
|
||||
raise ValueError(f"Dock with name {name} already exists.")
|
||||
|
||||
if position is None:
|
||||
position = "bottom"
|
||||
dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
|
||||
if name is not None: # Name is provided
|
||||
if name in dock_names:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self._name} and id {self.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
|
||||
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
|
||||
dock.config.position = position
|
||||
self.config.docks[name] = dock.config
|
||||
|
||||
self.config.docks[dock.name()] = dock.config
|
||||
# The dock.name is equal to the name passed to BECDock
|
||||
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
@@ -341,10 +362,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.show_title_bar()
|
||||
|
||||
if widget is not None and isinstance(widget, str):
|
||||
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
elif widget is not None and isinstance(widget, QWidget):
|
||||
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
if widget is not None:
|
||||
# Check if widget name exists.
|
||||
dock.new(
|
||||
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
|
||||
)
|
||||
if (
|
||||
self._instructions_visible
|
||||
): # TODO still decide how initial instructions should be handled
|
||||
@@ -352,8 +374,14 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.update()
|
||||
if floating:
|
||||
dock.detach()
|
||||
# Run broadcast update
|
||||
self._broadcast_update()
|
||||
return dock
|
||||
|
||||
def _broadcast_update(self):
|
||||
rpc_register = RPCRegister()
|
||||
rpc_register.broadcast()
|
||||
|
||||
def detach_dock(self, dock_name: str) -> BECDock:
|
||||
"""
|
||||
Undock a dock from the dock area.
|
||||
@@ -382,58 +410,90 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Remove a temporary area from the dock area.
|
||||
This is a patched method of pyqtgraph's removeTempArea
|
||||
"""
|
||||
if area not in self.dock_area.tempAreas:
|
||||
# FIXME add some context for the logging, I am not sure which object is passed.
|
||||
# It looks like a pyqtgraph.DockArea
|
||||
logger.info(f"Attempted to remove dock_area, but was not floating.")
|
||||
return
|
||||
self.dock_area.tempAreas.remove(area)
|
||||
area.window().close()
|
||||
area.window().deleteLater()
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
Close all docks and remove all temp areas.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock in dict(self.dock_area.docks).values():
|
||||
dock.remove()
|
||||
self.dock_area.docks.clear()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.clear_all()
|
||||
self.delete_all()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
|
||||
def show(self):
|
||||
"""Show all windows including floating docks."""
|
||||
super().show()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().show()
|
||||
|
||||
def hide(self):
|
||||
"""Hide all windows including floating docks."""
|
||||
super().hide()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().hide()
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""
|
||||
Delete all docks.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock_name in self.panels.keys():
|
||||
self.delete(dock_name)
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
def delete(self, dock_name: str):
|
||||
"""
|
||||
Delete a dock by name.
|
||||
|
||||
Args:
|
||||
dock_name(str): The name of the dock to delete.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(dock_name, None)
|
||||
self.config.docks.pop(dock_name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
else:
|
||||
raise ValueError(f"Dock with name {dock_name} does not exist.")
|
||||
self._broadcast_update()
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Remove the dock area."""
|
||||
self.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
dock_area.new(widget="Waveform")
|
||||
dock_area.show()
|
||||
dock_area.setGeometry(100, 100, 800, 600)
|
||||
app.topLevelWidgets()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -78,13 +78,7 @@ class WidgetHandler:
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
self,
|
||||
widget_type: str,
|
||||
widget_id: str,
|
||||
parent_figure,
|
||||
parent_id: str,
|
||||
config: dict = None,
|
||||
**axis_kwargs,
|
||||
self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Create and configure a widget based on its type.
|
||||
@@ -109,7 +103,6 @@ class WidgetHandler:
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
"gui_id": widget_id,
|
||||
**(config if config is not None else {}),
|
||||
}
|
||||
widget_config = config_class(**widget_config_dict)
|
||||
@@ -162,13 +155,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
config: Optional[FigureConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = FigureConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = FigureConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
pg.GraphicsLayoutWidget.__init__(self, parent)
|
||||
|
||||
self.widget_handler = WidgetHandler()
|
||||
@@ -567,15 +561,12 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
|
||||
widget = self.widget_handler.create_widget(
|
||||
widget_type=widget_type,
|
||||
widget_id=widget_id,
|
||||
parent_figure=self,
|
||||
parent_id=self.gui_id,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
|
||||
# used otherwise multiple times
|
||||
widget.set_gui_id(widget_id)
|
||||
widget_id = widget.gui_id
|
||||
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
@@ -588,6 +579,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
self.config.num_cols = max(self.config.num_cols, col + 1)
|
||||
|
||||
# Saving config for future referencing
|
||||
|
||||
self.config.widgets[widget_id] = widget.config
|
||||
self._widgets[widget_id] = widget
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
super().__init__(config=config, gui_id=gui_id, **kwargs)
|
||||
pg.ImageItem.__init__(self)
|
||||
|
||||
self.parent_image = parent_image
|
||||
|
||||
@@ -338,16 +338,3 @@ class BECMultiWaveform(BECPlotBase):
|
||||
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
|
||||
"""
|
||||
MatplotlibExporter(self.plot_item).export()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECFigure()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -98,10 +98,11 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
config: Optional[SubplotConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = SubplotConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
pg.GraphicsLayout.__init__(self, parent)
|
||||
|
||||
self.figure = parent_figure
|
||||
|
||||
@@ -99,11 +99,17 @@ class BECWaveform(BECPlotBase):
|
||||
config: Optional[Waveform1DConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = Waveform1DConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
parent=parent,
|
||||
parent_figure=parent_figure,
|
||||
config=config,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._curves_data = defaultdict(dict)
|
||||
@@ -1242,16 +1248,22 @@ class BECWaveform(BECPlotBase):
|
||||
msg(dict): Message with the async data.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
instruction = metadata.get("async_update")
|
||||
y_data = None
|
||||
x_data = None
|
||||
instruction = metadata.get("async_update", {}).get("type")
|
||||
max_shape = metadata.get("async_update", {}).get("max_shape", [])
|
||||
for curve in self._curves_data["async"].values():
|
||||
y_name = curve.config.signals.y.name
|
||||
y_entry = curve.config.signals.y.entry
|
||||
x_name = self._x_axis_mode["name"]
|
||||
for device, async_data in msg["signals"].items():
|
||||
if device == y_entry:
|
||||
data_plot = async_data["value"]
|
||||
if instruction == "extend":
|
||||
x_data, y_data = curve.get_data()
|
||||
if instruction == "add":
|
||||
if len(max_shape) > 1:
|
||||
if len(data_plot.shape) > 1:
|
||||
data_plot = data_plot[-1, :]
|
||||
else:
|
||||
x_data, y_data = curve.get_data()
|
||||
if y_data is not None:
|
||||
new_data = np.hstack((y_data, data_plot))
|
||||
else:
|
||||
|
||||
@@ -97,7 +97,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
else:
|
||||
self.config = config
|
||||
# config.widget_class = self.__class__.__name__
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
super().__init__(config=config, gui_id=gui_id, **kwargs)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
self.parent_item = parent_item
|
||||
|
||||
882
bec_widgets/widgets/containers/layout_manager/layout_manager.py
Normal file
882
bec_widgets/widgets/containers/layout_manager/layout_manager.py
Normal file
@@ -0,0 +1,882 @@
|
||||
import math
|
||||
import sys
|
||||
from typing import Dict, Literal, Optional, Set, Tuple, Union
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
|
||||
|
||||
class LayoutManagerWidget(QWidget):
|
||||
"""
|
||||
A robust layout manager that extends QGridLayout functionality, allowing
|
||||
users to add/remove widgets, access widgets by coordinates, shift widgets,
|
||||
and change the layout dynamically with automatic reindexing to keep the grid compact.
|
||||
|
||||
Supports adding widgets via QWidget instances or string identifiers referencing the widget handler.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, auto_reindex=True):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("LayoutManagerWidget")
|
||||
self.layout = QGridLayout(self)
|
||||
self.auto_reindex = auto_reindex
|
||||
|
||||
# Mapping from widget to its position (row, col, rowspan, colspan)
|
||||
self.widget_positions: Dict[QWidget, Tuple[int, int, int, int]] = {}
|
||||
|
||||
# Mapping from (row, col) to widget
|
||||
self.position_widgets: Dict[Tuple[int, int], QWidget] = {}
|
||||
|
||||
# Keep track of the current position for automatic placement
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
row: int | None = None,
|
||||
col: Optional[int] = None,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> QWidget:
|
||||
"""
|
||||
Add a widget to the grid with enhanced shifting capabilities.
|
||||
|
||||
Args:
|
||||
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
|
||||
row (int, optional): The row to add the widget to. If None, the next available row is used.
|
||||
col (int, optional): The column to add the widget to. If None, the next available column is used.
|
||||
rowspan (int): Number of rows the widget spans. Default is 1.
|
||||
colspan (int): Number of columns the widget spans. Default is 1.
|
||||
shift_existing (bool): Whether to shift existing widgets if the target position is occupied. Default is True.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. Default is "right".
|
||||
|
||||
Returns:
|
||||
QWidget: The widget that was added.
|
||||
"""
|
||||
# Handle widget creation if a BECWidget string identifier is provided
|
||||
if isinstance(widget, str):
|
||||
widget = widget_handler.create_widget(widget)
|
||||
|
||||
if row is None:
|
||||
row = self.current_row
|
||||
if col is None:
|
||||
col = self.current_col
|
||||
|
||||
if (row, col) in self.position_widgets:
|
||||
if shift_existing:
|
||||
# Attempt to shift the existing widget in the specified direction
|
||||
self.shift_widgets(direction=shift_direction, start_row=row, start_col=col)
|
||||
else:
|
||||
raise ValueError(f"Position ({row}, {col}) is already occupied.")
|
||||
|
||||
# Add the widget to the layout
|
||||
self.layout.addWidget(widget, row, col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (row, col, rowspan, colspan)
|
||||
self.position_widgets[(row, col)] = widget
|
||||
|
||||
# Update current position for automatic placement
|
||||
self.current_col = col + colspan
|
||||
self.current_row = max(self.current_row, row)
|
||||
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
return widget
|
||||
|
||||
def add_widget_relative(
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
reference_widget: QWidget,
|
||||
position: Literal["left", "right", "top", "bottom"],
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> QWidget:
|
||||
"""
|
||||
Add a widget relative to an existing widget.
|
||||
|
||||
Args:
|
||||
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
|
||||
reference_widget (QWidget): The widget relative to which the new widget will be placed.
|
||||
position (Literal["left", "right", "top", "bottom"]): Position relative to the reference widget.
|
||||
rowspan (int): Number of rows the widget spans. Default is 1.
|
||||
colspan (int): Number of columns the widget spans. Default is 1.
|
||||
shift_existing (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Returns:
|
||||
QWidget: The widget that was added.
|
||||
|
||||
Raises:
|
||||
ValueError: If the reference widget is not found.
|
||||
"""
|
||||
if reference_widget not in self.widget_positions:
|
||||
raise ValueError("Reference widget not found in layout.")
|
||||
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
|
||||
# Determine new widget position based on the specified relative position
|
||||
if position == "left":
|
||||
new_row = ref_row
|
||||
new_col = ref_col - 1
|
||||
elif position == "right":
|
||||
new_row = ref_row
|
||||
new_col = ref_col + ref_colspan
|
||||
elif position == "top":
|
||||
new_row = ref_row - 1
|
||||
new_col = ref_col
|
||||
elif position == "bottom":
|
||||
new_row = ref_row + ref_rowspan
|
||||
new_col = ref_col
|
||||
else:
|
||||
raise ValueError("Invalid position. Choose from 'left', 'right', 'top', 'bottom'.")
|
||||
|
||||
# Add the widget at the calculated position
|
||||
return self.add_widget(
|
||||
widget=widget,
|
||||
row=new_row,
|
||||
col=new_col,
|
||||
rowspan=rowspan,
|
||||
colspan=colspan,
|
||||
shift_existing=shift_existing,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
def move_widget_by_coords(
|
||||
self,
|
||||
current_row: int,
|
||||
current_col: int,
|
||||
new_row: int,
|
||||
new_col: int,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget from (current_row, current_col) to (new_row, new_col).
|
||||
|
||||
Args:
|
||||
current_row (int): Current row of the widget.
|
||||
current_col (int): Current column of the widget.
|
||||
new_row (int): Target row.
|
||||
new_col (int): Target column.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
self.move_widget(
|
||||
old_row=current_row,
|
||||
old_col=current_col,
|
||||
new_row=new_row,
|
||||
new_col=new_col,
|
||||
shift=shift,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
@typechecked
|
||||
def move_widget_by_object(
|
||||
self,
|
||||
widget: QWidget,
|
||||
new_row: int,
|
||||
new_col: int,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget to a new position using the widget object.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to move.
|
||||
new_row (int): Target row.
|
||||
new_col (int): Target column.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
if widget not in self.widget_positions:
|
||||
raise ValueError("Widget not found in layout.")
|
||||
|
||||
old_position = self.widget_positions[widget]
|
||||
old_row, old_col = old_position[0], old_position[1]
|
||||
|
||||
self.move_widget(
|
||||
old_row=old_row,
|
||||
old_col=old_col,
|
||||
new_row=new_row,
|
||||
new_col=new_col,
|
||||
shift=shift,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
@typechecked
|
||||
def move_widget(
|
||||
self,
|
||||
old_row: int | None = None,
|
||||
old_col: int | None = None,
|
||||
new_row: int | None = None,
|
||||
new_col: int | None = None,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget to a new position. If the new position is occupied and shift is True,
|
||||
shift the existing widget to the specified direction.
|
||||
|
||||
Args:
|
||||
old_row (int, optional): The current row of the widget.
|
||||
old_col (int, optional): The current column of the widget.
|
||||
new_row (int, optional): The target row to move the widget to.
|
||||
new_col (int, optional): The target column to move the widget to.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
if new_row is None or new_col is None:
|
||||
raise ValueError("Must provide both new_row and new_col to move a widget.")
|
||||
|
||||
if old_row is None and old_col is None:
|
||||
raise ValueError(f"No widget found at position ({old_row}, {old_col}).")
|
||||
widget = self.get_widget(old_row, old_col)
|
||||
|
||||
if (new_row, new_col) in self.position_widgets:
|
||||
if not shift:
|
||||
raise ValueError(f"Position ({new_row}, {new_col}) is already occupied.")
|
||||
# Shift the existing widget to make space
|
||||
self.shift_widgets(
|
||||
direction=shift_direction,
|
||||
start_row=new_row if shift_direction in ["down", "up"] else 0,
|
||||
start_col=new_col if shift_direction in ["left", "right"] else 0,
|
||||
)
|
||||
|
||||
# Proceed to move the widget
|
||||
self.layout.removeWidget(widget)
|
||||
old_position = self.widget_positions.pop(widget)
|
||||
self.position_widgets.pop((old_position[0], old_position[1]))
|
||||
|
||||
self.layout.addWidget(widget, new_row, new_col, old_position[2], old_position[3])
|
||||
self.widget_positions[widget] = (new_row, new_col, old_position[2], old_position[3])
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col for automatic placement if needed
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
self.current_col = max(self.current_col, new_col + old_position[3])
|
||||
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
@typechecked
|
||||
def shift_widgets(
|
||||
self,
|
||||
direction: Literal["down", "up", "left", "right"],
|
||||
start_row: int = 0,
|
||||
start_col: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Shift widgets in the grid in the specified direction starting from the given position.
|
||||
|
||||
Args:
|
||||
direction (Literal["down", "up", "left", "right"]): Direction to shift widgets.
|
||||
start_row (int): Starting row index.
|
||||
start_col (int): Starting column index.
|
||||
|
||||
Raises:
|
||||
ValueError: If shifting causes widgets to go out of grid boundaries.
|
||||
"""
|
||||
shifts = []
|
||||
positions_to_shift = [(start_row, start_col)]
|
||||
visited_positions = set()
|
||||
|
||||
while positions_to_shift:
|
||||
row, col = positions_to_shift.pop(0)
|
||||
if (row, col) in visited_positions:
|
||||
continue
|
||||
visited_positions.add((row, col))
|
||||
|
||||
widget = self.position_widgets.get((row, col))
|
||||
if widget is None:
|
||||
continue # No widget at this position
|
||||
|
||||
# Compute new position based on the direction
|
||||
if direction == "down":
|
||||
new_row = row + 1
|
||||
new_col = col
|
||||
elif direction == "up":
|
||||
new_row = row - 1
|
||||
new_col = col
|
||||
elif direction == "right":
|
||||
new_row = row
|
||||
new_col = col + 1
|
||||
elif direction == "left":
|
||||
new_row = row
|
||||
new_col = col - 1
|
||||
|
||||
# Check for negative indices
|
||||
if new_row < 0 or new_col < 0:
|
||||
raise ValueError("Shifting widgets out of grid boundaries.")
|
||||
|
||||
# If the new position is occupied, add it to the positions to shift
|
||||
if (new_row, new_col) in self.position_widgets:
|
||||
positions_to_shift.append((new_row, new_col))
|
||||
|
||||
shifts.append(
|
||||
(widget, (row, col), (new_row, new_col), self.widget_positions[widget][2:])
|
||||
)
|
||||
|
||||
# Remove all widgets from their old positions
|
||||
for widget, (old_row, old_col), _, _ in shifts:
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop((old_row, old_col))
|
||||
|
||||
# Add widgets to their new positions
|
||||
for widget, _, (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col if needed
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
self.current_col = max(self.current_col, new_col + colspan)
|
||||
|
||||
def shift_all_widgets(self, direction: Literal["down", "up", "left", "right"]) -> None:
|
||||
"""
|
||||
Shift all widgets in the grid in the specified direction to make room and prevent negative indices.
|
||||
|
||||
Args:
|
||||
direction (Literal["down", "up", "left", "right"]): Direction to shift all widgets.
|
||||
"""
|
||||
# First, collect all the shifts to perform
|
||||
shifts = []
|
||||
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
|
||||
|
||||
if direction == "down":
|
||||
new_row = row + 1
|
||||
new_col = col
|
||||
elif direction == "up":
|
||||
new_row = row - 1
|
||||
new_col = col
|
||||
elif direction == "right":
|
||||
new_row = row
|
||||
new_col = col + 1
|
||||
elif direction == "left":
|
||||
new_row = row
|
||||
new_col = col - 1
|
||||
|
||||
# Check for negative indices
|
||||
if new_row < 0 or new_col < 0:
|
||||
raise ValueError("Shifting widgets out of grid boundaries.")
|
||||
|
||||
shifts.append((widget, (row, col), (new_row, new_col), (rowspan, colspan)))
|
||||
|
||||
# Now perform the shifts
|
||||
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop((old_row, old_col))
|
||||
|
||||
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col based on new widget positions
|
||||
self.current_row = max((pos[0] for pos in self.position_widgets.keys()), default=0)
|
||||
self.current_col = max((pos[1] for pos in self.position_widgets.keys()), default=0)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
coordinates: Tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Remove a widget from the layout. Can be removed by widget ID or by coordinates.
|
||||
|
||||
Args:
|
||||
row (int, optional): The row coordinate of the widget to remove.
|
||||
col (int, optional): The column coordinate of the widget to remove.
|
||||
coordinates (tuple[int, int], optional): The (row, col) coordinates of the widget to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget to remove is not found.
|
||||
"""
|
||||
if coordinates:
|
||||
row, col = coordinates
|
||||
widget = self.get_widget(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget found at coordinates {coordinates}.")
|
||||
elif row is not None and col is not None:
|
||||
widget = self.get_widget(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget found at position ({row}, {col}).")
|
||||
else:
|
||||
raise ValueError(
|
||||
"Must provide either widget_id, coordinates, or both row and col for removal."
|
||||
)
|
||||
|
||||
self.remove_widget(widget)
|
||||
|
||||
def remove_widget(self, widget: QWidget) -> None:
|
||||
"""
|
||||
Remove a widget from the grid and reindex the grid to keep it compact.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found in the layout.
|
||||
"""
|
||||
if widget not in self.widget_positions:
|
||||
raise ValueError("Widget not found in layout.")
|
||||
|
||||
position = self.widget_positions.pop(widget)
|
||||
self.position_widgets.pop((position[0], position[1]))
|
||||
self.layout.removeWidget(widget)
|
||||
widget.setParent(None) # Remove widget from the parent
|
||||
widget.deleteLater()
|
||||
|
||||
# Reindex the grid to maintain compactness
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
def get_widget(self, row: int, col: int) -> QWidget | None:
|
||||
"""
|
||||
Get the widget at the specified position.
|
||||
|
||||
Args:
|
||||
row (int): The row coordinate.
|
||||
col (int): The column coordinate.
|
||||
|
||||
Returns:
|
||||
QWidget | None: The widget at the specified position, or None if empty.
|
||||
"""
|
||||
return self.position_widgets.get((row, col))
|
||||
|
||||
def get_widget_position(self, widget: QWidget) -> Tuple[int, int, int, int] | None:
|
||||
"""
|
||||
Get the position of the specified widget.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to query.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int, int] | None: The (row, col, rowspan, colspan) tuple, or None if not found.
|
||||
"""
|
||||
return self.widget_positions.get(widget)
|
||||
|
||||
def change_layout(self, num_rows: int | None = None, num_cols: int | None = None) -> None:
|
||||
"""
|
||||
Change the layout to have a certain number of rows and/or columns,
|
||||
rearranging the widgets accordingly.
|
||||
|
||||
If only one of num_rows or num_cols is provided, the other is calculated automatically
|
||||
based on the number of widgets and the provided constraint.
|
||||
|
||||
If both are provided, num_rows is calculated based on num_cols.
|
||||
|
||||
Args:
|
||||
num_rows (int | None): The new maximum number of rows.
|
||||
num_cols (int | None): The new maximum number of columns.
|
||||
"""
|
||||
if num_rows is None and num_cols is None:
|
||||
return # Nothing to change
|
||||
|
||||
total_widgets = len(self.widget_positions)
|
||||
|
||||
if num_cols is not None:
|
||||
# Calculate num_rows based on num_cols
|
||||
num_rows = math.ceil(total_widgets / num_cols)
|
||||
elif num_rows is not None:
|
||||
# Calculate num_cols based on num_rows
|
||||
num_cols = math.ceil(total_widgets / num_rows)
|
||||
|
||||
# Sort widgets by current position (row-major order)
|
||||
widgets_sorted = sorted(
|
||||
self.widget_positions.items(),
|
||||
key=lambda item: (item[1][0], item[1][1]), # Sort by row, then column
|
||||
)
|
||||
|
||||
# Clear the layout without deleting widgets
|
||||
for widget, _ in widgets_sorted:
|
||||
self.layout.removeWidget(widget)
|
||||
|
||||
# Reset position mappings
|
||||
self.widget_positions.clear()
|
||||
self.position_widgets.clear()
|
||||
|
||||
# Re-add widgets based on new layout constraints
|
||||
current_row, current_col = 0, 0
|
||||
for widget, _ in widgets_sorted:
|
||||
if current_col >= num_cols:
|
||||
current_col = 0
|
||||
current_row += 1
|
||||
self.layout.addWidget(widget, current_row, current_col, 1, 1)
|
||||
self.widget_positions[widget] = (current_row, current_col, 1, 1)
|
||||
self.position_widgets[(current_row, current_col)] = widget
|
||||
current_col += 1
|
||||
|
||||
# Update current_row and current_col for automatic placement
|
||||
self.current_row = current_row
|
||||
self.current_col = current_col
|
||||
|
||||
# Reindex the grid to ensure compactness
|
||||
self.reindex_grid()
|
||||
|
||||
def clear_layout(self) -> None:
|
||||
"""
|
||||
Remove all widgets from the layout without deleting them.
|
||||
"""
|
||||
for widget in list(self.widget_positions):
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop(
|
||||
(self.widget_positions[widget][0], self.widget_positions[widget][1])
|
||||
)
|
||||
self.widget_positions.pop(widget)
|
||||
widget.setParent(None) # Optionally hide/remove the widget
|
||||
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
def reindex_grid(self) -> None:
|
||||
"""
|
||||
Reindex the grid to remove empty rows and columns, ensuring that
|
||||
widget coordinates are contiguous and start from (0, 0).
|
||||
"""
|
||||
# Step 1: Collect all occupied positions
|
||||
occupied_positions = sorted(self.position_widgets.keys())
|
||||
|
||||
if not occupied_positions:
|
||||
# No widgets to reindex
|
||||
self.clear_layout()
|
||||
return
|
||||
|
||||
# Step 2: Determine the new mapping by eliminating empty columns and rows
|
||||
# Find unique rows and columns
|
||||
unique_rows = sorted(set(pos[0] for pos in occupied_positions))
|
||||
unique_cols = sorted(set(pos[1] for pos in occupied_positions))
|
||||
|
||||
# Create mappings from old to new indices
|
||||
row_mapping = {old_row: new_row for new_row, old_row in enumerate(unique_rows)}
|
||||
col_mapping = {old_col: new_col for new_col, old_col in enumerate(unique_cols)}
|
||||
|
||||
# Step 3: Collect widgets with their new positions
|
||||
widgets_with_new_positions = []
|
||||
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
|
||||
new_row = row_mapping[row]
|
||||
new_col = col_mapping[col]
|
||||
widgets_with_new_positions.append((widget, new_row, new_col, rowspan, colspan))
|
||||
|
||||
# Step 4: Clear the layout and reset mappings
|
||||
self.clear_layout()
|
||||
|
||||
# Reset current_row and current_col
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
# Step 5: Re-add widgets with new positions
|
||||
for widget, new_row, new_col, rowspan, colspan in widgets_with_new_positions:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current position for automatic placement
|
||||
self.current_col = max(self.current_col, new_col + colspan)
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
|
||||
def get_widgets_positions(self) -> Dict[QWidget, Tuple[int, int, int, int]]:
|
||||
"""
|
||||
Get the positions of all widgets in the layout.
|
||||
|
||||
Returns:
|
||||
Dict[QWidget, Tuple[int, int, int, int]]: Mapping of widgets to their (row, col, rowspan, colspan).
|
||||
"""
|
||||
return self.widget_positions.copy()
|
||||
|
||||
def print_all_button_text(self):
|
||||
"""Debug function to print the text of all QPushButton widgets."""
|
||||
print("Coordinates - Button Text")
|
||||
for coord, widget in self.position_widgets.items():
|
||||
if isinstance(widget, QPushButton):
|
||||
print(f"{coord} - {widget.text()}")
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# The following code is for the GUI control panel to interact with the LayoutManagerWidget.
|
||||
# It is not covered by any tests as it serves only as an example for the LayoutManagerWidget class.
|
||||
####################################################################################################
|
||||
|
||||
|
||||
class ControlPanel(QWidget): # pragma: no cover
|
||||
def __init__(self, layout_manager: LayoutManagerWidget):
|
||||
super().__init__()
|
||||
self.layout_manager = layout_manager
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
# Add Widget by Coordinates
|
||||
add_coord_group = QGroupBox("Add Widget by Coordinates")
|
||||
add_coord_layout = QGridLayout()
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Text:"), 0, 0)
|
||||
self.text_input = QLineEdit()
|
||||
add_coord_layout.addWidget(self.text_input, 0, 1)
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Row:"), 1, 0)
|
||||
self.row_input = QSpinBox()
|
||||
self.row_input.setMinimum(0)
|
||||
add_coord_layout.addWidget(self.row_input, 1, 1)
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Column:"), 2, 0)
|
||||
self.col_input = QSpinBox()
|
||||
self.col_input.setMinimum(0)
|
||||
add_coord_layout.addWidget(self.col_input, 2, 1)
|
||||
|
||||
self.add_button = QPushButton("Add at Coordinates")
|
||||
self.add_button.clicked.connect(self.add_at_coordinates)
|
||||
add_coord_layout.addWidget(self.add_button, 3, 0, 1, 2)
|
||||
|
||||
add_coord_group.setLayout(add_coord_layout)
|
||||
main_layout.addWidget(add_coord_group)
|
||||
|
||||
# Add Widget Relative
|
||||
add_rel_group = QGroupBox("Add Widget Relative to Existing")
|
||||
add_rel_layout = QGridLayout()
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Text:"), 0, 0)
|
||||
self.rel_text_input = QLineEdit()
|
||||
add_rel_layout.addWidget(self.rel_text_input, 0, 1)
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Reference Widget:"), 1, 0)
|
||||
self.ref_widget_combo = QComboBox()
|
||||
add_rel_layout.addWidget(self.ref_widget_combo, 1, 1)
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Position:"), 2, 0)
|
||||
self.position_combo = QComboBox()
|
||||
self.position_combo.addItems(["left", "right", "top", "bottom"])
|
||||
add_rel_layout.addWidget(self.position_combo, 2, 1)
|
||||
|
||||
self.add_rel_button = QPushButton("Add Relative")
|
||||
self.add_rel_button.clicked.connect(self.add_relative)
|
||||
add_rel_layout.addWidget(self.add_rel_button, 3, 0, 1, 2)
|
||||
|
||||
add_rel_group.setLayout(add_rel_layout)
|
||||
main_layout.addWidget(add_rel_group)
|
||||
|
||||
# Remove Widget
|
||||
remove_group = QGroupBox("Remove Widget")
|
||||
remove_layout = QGridLayout()
|
||||
|
||||
remove_layout.addWidget(QLabel("Row:"), 0, 0)
|
||||
self.remove_row_input = QSpinBox()
|
||||
self.remove_row_input.setMinimum(0)
|
||||
remove_layout.addWidget(self.remove_row_input, 0, 1)
|
||||
|
||||
remove_layout.addWidget(QLabel("Column:"), 1, 0)
|
||||
self.remove_col_input = QSpinBox()
|
||||
self.remove_col_input.setMinimum(0)
|
||||
remove_layout.addWidget(self.remove_col_input, 1, 1)
|
||||
|
||||
self.remove_button = QPushButton("Remove at Coordinates")
|
||||
self.remove_button.clicked.connect(self.remove_widget)
|
||||
remove_layout.addWidget(self.remove_button, 2, 0, 1, 2)
|
||||
|
||||
remove_group.setLayout(remove_layout)
|
||||
main_layout.addWidget(remove_group)
|
||||
|
||||
# Change Layout
|
||||
change_layout_group = QGroupBox("Change Layout")
|
||||
change_layout_layout = QGridLayout()
|
||||
|
||||
change_layout_layout.addWidget(QLabel("Number of Rows:"), 0, 0)
|
||||
self.change_rows_input = QSpinBox()
|
||||
self.change_rows_input.setMinimum(1)
|
||||
self.change_rows_input.setValue(1) # Default value
|
||||
change_layout_layout.addWidget(self.change_rows_input, 0, 1)
|
||||
|
||||
change_layout_layout.addWidget(QLabel("Number of Columns:"), 1, 0)
|
||||
self.change_cols_input = QSpinBox()
|
||||
self.change_cols_input.setMinimum(1)
|
||||
self.change_cols_input.setValue(1) # Default value
|
||||
change_layout_layout.addWidget(self.change_cols_input, 1, 1)
|
||||
|
||||
self.change_layout_button = QPushButton("Apply Layout Change")
|
||||
self.change_layout_button.clicked.connect(self.change_layout)
|
||||
change_layout_layout.addWidget(self.change_layout_button, 2, 0, 1, 2)
|
||||
|
||||
change_layout_group.setLayout(change_layout_layout)
|
||||
main_layout.addWidget(change_layout_group)
|
||||
|
||||
# Remove All Widgets
|
||||
self.clear_all_button = QPushButton("Clear All Widgets")
|
||||
self.clear_all_button.clicked.connect(self.clear_all_widgets)
|
||||
main_layout.addWidget(self.clear_all_button)
|
||||
|
||||
# Refresh Reference Widgets and Print Button
|
||||
self.refresh_button = QPushButton("Refresh Reference Widgets")
|
||||
self.refresh_button.clicked.connect(self.refresh_references)
|
||||
self.print_button = QPushButton("Print All Button Text")
|
||||
self.print_button.clicked.connect(self.layout_manager.print_all_button_text)
|
||||
main_layout.addWidget(self.refresh_button)
|
||||
main_layout.addWidget(self.print_button)
|
||||
|
||||
main_layout.addStretch()
|
||||
self.setLayout(main_layout)
|
||||
self.refresh_references()
|
||||
|
||||
def refresh_references(self):
|
||||
self.ref_widget_combo.clear()
|
||||
widgets = self.layout_manager.get_widgets_positions()
|
||||
for widget in widgets:
|
||||
if isinstance(widget, QPushButton):
|
||||
self.ref_widget_combo.addItem(widget.text(), widget)
|
||||
|
||||
def add_at_coordinates(self):
|
||||
text = self.text_input.text()
|
||||
row = self.row_input.value()
|
||||
col = self.col_input.value()
|
||||
|
||||
if not text:
|
||||
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
|
||||
return
|
||||
|
||||
button = QPushButton(text)
|
||||
try:
|
||||
self.layout_manager.add_widget(widget=button, row=row, col=col)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def add_relative(self):
|
||||
text = self.rel_text_input.text()
|
||||
ref_index = self.ref_widget_combo.currentIndex()
|
||||
ref_widget = self.ref_widget_combo.itemData(ref_index)
|
||||
position = self.position_combo.currentText()
|
||||
|
||||
if not text:
|
||||
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
|
||||
return
|
||||
|
||||
if ref_widget is None:
|
||||
QMessageBox.warning(self, "Input Error", "Please select a reference widget.")
|
||||
return
|
||||
|
||||
button = QPushButton(text)
|
||||
try:
|
||||
self.layout_manager.add_widget_relative(
|
||||
widget=button, reference_widget=ref_widget, position=position
|
||||
)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def remove_widget(self):
|
||||
row = self.remove_row_input.value()
|
||||
col = self.remove_col_input.value()
|
||||
|
||||
try:
|
||||
widget = self.layout_manager.get_widget(row, col)
|
||||
if widget is None:
|
||||
QMessageBox.warning(self, "Not Found", f"No widget found at ({row}, {col}).")
|
||||
return
|
||||
self.layout_manager.remove_widget(widget)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def change_layout(self):
|
||||
num_rows = self.change_rows_input.value()
|
||||
num_cols = self.change_cols_input.value()
|
||||
|
||||
try:
|
||||
self.layout_manager.change_layout(num_rows=num_rows, num_cols=num_cols)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def clear_all_widgets(self):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear",
|
||||
"Are you sure you want to remove all widgets?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
self.layout_manager.clear_layout()
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Layout Manager Demo")
|
||||
self.resize(800, 600)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
central_widget = QWidget()
|
||||
main_layout = QHBoxLayout()
|
||||
|
||||
# Layout Area GroupBox
|
||||
layout_group = QGroupBox("Layout Area")
|
||||
layout_group.setMinimumSize(400, 400)
|
||||
layout_layout = QVBoxLayout()
|
||||
|
||||
self.layout_manager = LayoutManagerWidget()
|
||||
layout_layout.addWidget(self.layout_manager)
|
||||
|
||||
layout_group.setLayout(layout_layout)
|
||||
|
||||
# Splitter
|
||||
splitter = QSplitter()
|
||||
splitter.addWidget(layout_group)
|
||||
|
||||
# Control Panel
|
||||
control_panel = ControlPanel(self.layout_manager)
|
||||
control_group = QGroupBox("Control Panel")
|
||||
control_layout = QVBoxLayout()
|
||||
control_layout.addWidget(control_panel)
|
||||
control_layout.addStretch()
|
||||
control_group.setLayout(control_layout)
|
||||
splitter.addWidget(control_group)
|
||||
|
||||
main_layout.addWidget(splitter)
|
||||
central_widget.setLayout(main_layout)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,9 +1,75 @@
|
||||
from qtpy.QtWidgets import QMainWindow
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECMainWindow(QMainWindow, BECConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
BECConnector.__init__(self, **kwargs)
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
def __init__(self, gui_id: str = None, *args, **kwargs):
|
||||
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
|
||||
QMainWindow.__init__(self, *args, **kwargs)
|
||||
|
||||
def _dump(self):
|
||||
"""Return a dictionary with informations about the application state, for use in tests"""
|
||||
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
|
||||
# so, a filtering based on title is applied here, but the solution is to not have those widgets
|
||||
# as top-level (so for now, a window with no title does not appear in _dump() result)
|
||||
|
||||
# NOTE: the main window itself is excluded, since we want to dump dock areas
|
||||
info = {
|
||||
tlw.gui_id: {
|
||||
"title": tlw.windowTitle(),
|
||||
"visible": tlw.isVisible(),
|
||||
"class": str(type(tlw)),
|
||||
}
|
||||
for tlw in QApplication.instance().topLevelWidgets()
|
||||
if tlw is not self and tlw.windowTitle()
|
||||
}
|
||||
# Add the main window dock area
|
||||
info[self.centralWidget().gui_id] = {
|
||||
"title": self.windowTitle(),
|
||||
"visible": self.isVisible(),
|
||||
"class": str(type(self.centralWidget())),
|
||||
}
|
||||
return info
|
||||
|
||||
def new_dock_area(
|
||||
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> BECDockArea:
|
||||
"""Create a new dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
BECDockArea: The newly created dock area.
|
||||
"""
|
||||
rpc_register = RPCRegister()
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
if name in existing_dock_areas:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
dock_area = BECDockArea(name=name)
|
||||
dock_area.resize(dock_area.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
dock_area.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
logger.info(f"Existing dock areas: {geometry}")
|
||||
if geometry is not None:
|
||||
dock_area.setGeometry(*geometry)
|
||||
dock_area.show()
|
||||
return dock_area
|
||||
|
||||
def cleanup(self):
|
||||
# TODO
|
||||
super().close()
|
||||
|
||||
@@ -13,9 +13,16 @@ class AbortButton(BECWidget, QWidget):
|
||||
ICON_NAME = "cancel"
|
||||
|
||||
def __init__(
|
||||
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_id=None,
|
||||
toolbar=False,
|
||||
scan_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -12,8 +12,8 @@ class ResetButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "restart_alt"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -12,8 +12,8 @@ class ResumeButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "resume"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -12,8 +12,8 @@ class StopButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "dangerous"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -12,8 +12,8 @@ class PositionIndicator(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "horizontal_distribute"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.position = 50
|
||||
self.min_value = 0
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
|
||||
PositionerBox,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
|
||||
PositionerBox2D,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
|
||||
__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .positioner_box_base import PositionerBoxBase
|
||||
|
||||
__ALL__ = ["PositionerBoxBase"]
|
||||
@@ -0,0 +1,243 @@
|
||||
import uuid
|
||||
from abc import abstractmethod
|
||||
from ast import Tuple
|
||||
from typing import Callable, TypedDict
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanQueueMessage
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||
PositionIndicator,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceUpdateUIComponents(TypedDict):
|
||||
spinner: SpinnerWidget
|
||||
setpoint: QLineEdit
|
||||
readback: QLabel
|
||||
position_indicator: PositionIndicator
|
||||
step_size: QDoubleSpinBox
|
||||
device_box: QGroupBox
|
||||
stop: QPushButton
|
||||
tweak_increase: QPushButton
|
||||
tweak_decrease: QPushButton
|
||||
|
||||
|
||||
class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
"""Contains some core logic for positioner box widgets"""
|
||||
|
||||
current_path = ""
|
||||
ICON_NAME = "switch_right"
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
|
||||
self._dialog = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
def _check_device_is_valid(self, device: str):
|
||||
"""Check if the device is a positioner
|
||||
|
||||
Args:
|
||||
device (str): The device name
|
||||
"""
|
||||
if device not in self.dev:
|
||||
logger.info(f"Device {device} not found in the device list")
|
||||
return False
|
||||
if not isinstance(self.dev[device], Positioner):
|
||||
logger.info(f"Device {device} is not a positioner")
|
||||
return False
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents: ...
|
||||
|
||||
def _init_device(
|
||||
self,
|
||||
device: str,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
):
|
||||
"""Init the device view and readback"""
|
||||
if self._check_device_is_valid(device):
|
||||
data = self.dev[device].read()
|
||||
self._on_device_readback(
|
||||
device,
|
||||
self._device_ui_components(device),
|
||||
{"signals": data},
|
||||
{},
|
||||
position_emit,
|
||||
limit_update,
|
||||
)
|
||||
|
||||
def _stop_device(self, device: str):
|
||||
"""Stop call"""
|
||||
request_id = str(uuid.uuid4())
|
||||
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
|
||||
msg = ScanQueueMessage(
|
||||
scan_type="device_rpc",
|
||||
parameter=params,
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _on_device_readback(
|
||||
self,
|
||||
device: str,
|
||||
ui_components: DeviceUpdateUIComponents,
|
||||
msg_content: dict,
|
||||
metadata: dict,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
):
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[device]._hints
|
||||
precision = self.dev[device].precision
|
||||
|
||||
spinner = ui_components["spinner"]
|
||||
position_indicator = ui_components["position_indicator"]
|
||||
readback = ui_components["readback"]
|
||||
setpoint = ui_components["setpoint"]
|
||||
|
||||
readback_val = None
|
||||
setpoint_val = None
|
||||
|
||||
if len(hinted_signals) == 1:
|
||||
signal = hinted_signals[0]
|
||||
readback_val = signals.get(signal, {}).get("value")
|
||||
|
||||
for setpoint_signal in ["setpoint", "user_setpoint"]:
|
||||
setpoint_val = signals.get(f"{device}_{setpoint_signal}", {}).get("value")
|
||||
if setpoint_val is not None:
|
||||
break
|
||||
|
||||
for moving_signal in ["motor_done_move", "motor_is_moving"]:
|
||||
is_moving = signals.get(f"{device}_{moving_signal}", {}).get("value")
|
||||
if is_moving is not None:
|
||||
break
|
||||
|
||||
if is_moving is not None:
|
||||
spinner.setVisible(True)
|
||||
if is_moving:
|
||||
spinner.start()
|
||||
spinner.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
spinner.stop()
|
||||
spinner.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
spinner.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
readback.setText(f"{readback_val:.{precision}f}")
|
||||
position_emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
|
||||
limits = self.dev[device].limits
|
||||
limit_update(limits)
|
||||
if limits is not None and readback_val is not None and limits[0] != limits[1]:
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
position_indicator.set_value(pos)
|
||||
|
||||
def _update_limits_ui(
|
||||
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
||||
):
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
setpoint_validator.setRange(limits[0], limits[1])
|
||||
else:
|
||||
position_indicator.setToolTip("No limits set")
|
||||
setpoint_validator.setRange(float("-inf"), float("inf"))
|
||||
|
||||
def _update_device_ui(self, device: str, ui: DeviceUpdateUIComponents):
|
||||
ui["device_box"].setTitle(device)
|
||||
ui["readback"].setToolTip(f"{device} readback")
|
||||
ui["setpoint"].setToolTip(f"{device} setpoint")
|
||||
ui["step_size"].setToolTip(f"Step size for {device}")
|
||||
precision = self.dev[device].precision
|
||||
if precision is not None:
|
||||
ui["step_size"].setDecimals(precision)
|
||||
ui["step_size"].setValue(10**-precision * 10)
|
||||
|
||||
def _swap_readback_signal_connection(self, slot, old_device, new_device):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
||||
|
||||
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
"""
|
||||
ui["tweak_increase"].setEnabled(enable)
|
||||
ui["tweak_decrease"].setEnabled(enable)
|
||||
ui["stop"].setEnabled(enable)
|
||||
ui["setpoint"].setEnabled(enable)
|
||||
ui["step_size"].setEnabled(enable)
|
||||
|
||||
def _on_device_change(
|
||||
self,
|
||||
old_device: str,
|
||||
new_device: str,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
on_device_readback: Callable,
|
||||
ui: DeviceUpdateUIComponents,
|
||||
):
|
||||
logger.info(f"Device changed from {old_device} to {new_device}")
|
||||
self._toggle_enable_buttons(ui, True)
|
||||
self._init_device(new_device, position_emit, limit_update)
|
||||
self._swap_readback_signal_connection(on_device_readback, old_device, new_device)
|
||||
self._update_device_ui(new_device, ui)
|
||||
|
||||
def _open_dialog_selection(self, set_positioner: Callable):
|
||||
def _ods():
|
||||
"""Open dialog window for positioner selection"""
|
||||
self._dialog = QDialog(self)
|
||||
self._dialog.setWindowTitle("Positioner Selection")
|
||||
layout = QVBoxLayout()
|
||||
line_edit = DeviceLineEdit(
|
||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
line_edit.textChanged.connect(set_positioner)
|
||||
layout.addWidget(line_edit)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self._dialog.accept)
|
||||
layout.addWidget(close_button)
|
||||
self._dialog.setLayout(layout)
|
||||
self._dialog.exec()
|
||||
self._dialog = None
|
||||
|
||||
return _ods
|
||||
@@ -1,352 +0,0 @@
|
||||
""" Module for a PositionerBox widget to control a positioner device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanQueueMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerBox(BECWidget, CompactPopupWidget):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
ui_file = "positioner_box.ui"
|
||||
dimensions = (234, 224)
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "switch_right"
|
||||
USER_ACCESS = ["set_positioner"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, device: Positioner = None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
|
||||
self.get_bec_shortcuts()
|
||||
self._device = ""
|
||||
self._limits = None
|
||||
self._dialog = None
|
||||
|
||||
self.init_ui()
|
||||
|
||||
if device is not None:
|
||||
self.device = device
|
||||
self.init_device()
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed.connect(self.on_device_change)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(self).loader(os.path.join(current_path, self.ui_file))
|
||||
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# fix the size of the device box
|
||||
db = self.ui.device_box
|
||||
db.setFixedHeight(self.dimensions[0])
|
||||
db.setFixedWidth(self.dimensions[1])
|
||||
|
||||
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
|
||||
self.ui.stop.clicked.connect(self.on_stop)
|
||||
self.ui.stop.setToolTip("Stop")
|
||||
self.ui.stop.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
|
||||
)
|
||||
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
|
||||
self.ui.tweak_right.setToolTip("Tweak right")
|
||||
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
|
||||
self.ui.tweak_left.setToolTip("Tweak left")
|
||||
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
|
||||
|
||||
self.setpoint_validator = QDoubleValidator()
|
||||
self.ui.setpoint.setValidator(self.setpoint_validator)
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.tool_button.clicked.connect(self._open_dialog_selection)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button.setIcon(icon)
|
||||
|
||||
def _open_dialog_selection(self):
|
||||
"""Open dialog window for positioner selection"""
|
||||
self._dialog = QDialog(self)
|
||||
self._dialog.setWindowTitle("Positioner Selection")
|
||||
layout = QVBoxLayout()
|
||||
line_edit = DeviceLineEdit(
|
||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
line_edit.textChanged.connect(self.set_positioner)
|
||||
layout.addWidget(line_edit)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self._dialog.accept)
|
||||
layout.addWidget(close_button)
|
||||
self._dialog.setLayout(layout)
|
||||
self._dialog.exec()
|
||||
self._dialog = None
|
||||
|
||||
def init_device(self):
|
||||
"""Init the device view and readback"""
|
||||
if self._check_device_is_valid(self.device):
|
||||
data = self.dev[self.device].read()
|
||||
self.on_device_readback({"signals": data}, {})
|
||||
|
||||
def _toogle_enable_buttons(self, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
"""
|
||||
self.ui.tweak_left.setEnabled(enable)
|
||||
self.ui.tweak_right.setEnabled(enable)
|
||||
self.ui.stop.setEnabled(enable)
|
||||
self.ui.setpoint.setEnabled(enable)
|
||||
self.ui.step_size.setEnabled(enable)
|
||||
|
||||
@Property(str)
|
||||
def device(self):
|
||||
"""Property to set the device"""
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@Property(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button.setVisible(not value)
|
||||
|
||||
@Slot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@Slot(str)
|
||||
def set_positioner(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device = positioner
|
||||
|
||||
def _check_device_is_valid(self, device: str):
|
||||
"""Check if the device is a positioner
|
||||
|
||||
Args:
|
||||
device (str): The device name
|
||||
"""
|
||||
if device not in self.dev:
|
||||
logger.info(f"Device {device} not found in the device list")
|
||||
return False
|
||||
if not isinstance(self.dev[device], Positioner):
|
||||
logger.info(f"Device {device} is not a positioner")
|
||||
return False
|
||||
return True
|
||||
|
||||
@Slot(str, str)
|
||||
def on_device_change(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
logger.info(f"Device changed from {old_device} to {new_device}")
|
||||
self._toogle_enable_buttons(True)
|
||||
self.init_device()
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_device_readback, MessageEndpoints.device_readback(old_device)
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_device_readback, MessageEndpoints.device_readback(new_device)
|
||||
)
|
||||
self.ui.device_box.setTitle(new_device)
|
||||
self.ui.readback.setToolTip(f"{self.device} readback")
|
||||
self.ui.setpoint.setToolTip(f"{self.device} setpoint")
|
||||
self.ui.step_size.setToolTip(f"Step size for {new_device}")
|
||||
|
||||
precision = self.dev[new_device].precision
|
||||
if precision is not None:
|
||||
self.ui.step_size.setDecimals(precision)
|
||||
self.ui.step_size.setValue(10**-precision * 10)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@Slot(dict, dict)
|
||||
def on_device_readback(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[self.device]._hints
|
||||
precision = self.dev[self.device].precision
|
||||
|
||||
readback_val = None
|
||||
setpoint_val = None
|
||||
|
||||
if len(hinted_signals) == 1:
|
||||
signal = hinted_signals[0]
|
||||
readback_val = signals.get(signal, {}).get("value")
|
||||
|
||||
for setpoint_signal in ["setpoint", "user_setpoint"]:
|
||||
setpoint_val = signals.get(f"{self.device}_{setpoint_signal}", {}).get("value")
|
||||
if setpoint_val is not None:
|
||||
break
|
||||
|
||||
for moving_signal in ["motor_done_move", "motor_is_moving"]:
|
||||
is_moving = signals.get(f"{self.device}_{moving_signal}", {}).get("value")
|
||||
if is_moving is not None:
|
||||
break
|
||||
|
||||
if is_moving is not None:
|
||||
self.ui.spinner_widget.setVisible(True)
|
||||
if is_moving:
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.spinner_widget.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
self.ui.spinner_widget.stop()
|
||||
self.ui.spinner_widget.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
self.ui.spinner_widget.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
self.ui.readback.setText(f"{readback_val:.{precision}f}")
|
||||
self.position_update.emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
|
||||
limits = self.dev[self.device].limits
|
||||
self.update_limits(limits)
|
||||
if limits is not None and readback_val is not None and limits[0] != limits[1]:
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
self.ui.position_indicator.set_value(pos)
|
||||
|
||||
def update_limits(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits:
|
||||
return
|
||||
self._limits = limits
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
self.ui.position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
self.setpoint_validator.setRange(limits[0], limits[1])
|
||||
else:
|
||||
self.ui.position_indicator.setToolTip("No limits set")
|
||||
self.setpoint_validator.setRange(float("-inf"), float("inf"))
|
||||
|
||||
@Slot()
|
||||
def on_stop(self):
|
||||
"""Stop call"""
|
||||
request_id = str(uuid.uuid4())
|
||||
params = {
|
||||
"device": self.device,
|
||||
"rpc_id": request_id,
|
||||
"func": "stop",
|
||||
"args": [],
|
||||
"kwargs": {},
|
||||
}
|
||||
msg = ScanQueueMessage(
|
||||
scan_type="device_rpc",
|
||||
parameter=params,
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
@property
|
||||
def step_size(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size.value()
|
||||
|
||||
@Slot()
|
||||
def on_tweak_right(self):
|
||||
"""Tweak motor right"""
|
||||
self.dev[self.device].move(self.step_size, relative=True)
|
||||
|
||||
@Slot()
|
||||
def on_tweak_left(self):
|
||||
"""Tweak motor left"""
|
||||
self.dev[self.device].move(-self.step_size, relative=True)
|
||||
|
||||
@Slot()
|
||||
def on_setpoint_change(self):
|
||||
"""Change the setpoint for the motor"""
|
||||
self.ui.setpoint.clearFocus()
|
||||
setpoint = self.ui.setpoint.text()
|
||||
self.dev[self.device].move(float(setpoint), relative=False)
|
||||
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
|
||||
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,242 @@
|
||||
""" Module for a PositionerBox widget to control a positioner device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerBox(PositionerBoxBase):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
ui_file = "positioner_box.ui"
|
||||
dimensions = (234, 224)
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = ["set_positioner"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
self._device = ""
|
||||
self._limits = None
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
|
||||
self.init_ui()
|
||||
self.device = device
|
||||
self._init_device(self.device, self.position_update.emit, self.update_limits)
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed.connect(self.on_device_change)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# fix the size of the device box
|
||||
db = self.ui.device_box
|
||||
db.setFixedHeight(self.dimensions[0])
|
||||
db.setFixedWidth(self.dimensions[1])
|
||||
|
||||
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
|
||||
self.ui.stop.clicked.connect(self.on_stop)
|
||||
self.ui.stop.setToolTip("Stop")
|
||||
self.ui.stop.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
|
||||
)
|
||||
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
|
||||
self.ui.tweak_right.setToolTip("Tweak right")
|
||||
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
|
||||
self.ui.tweak_left.setToolTip("Tweak left")
|
||||
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
|
||||
|
||||
self.setpoint_validator = QDoubleValidator()
|
||||
self.ui.setpoint.setValidator(self.setpoint_validator)
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button.setIcon(icon)
|
||||
|
||||
def force_update_readback(self):
|
||||
self._init_device(self.device, self.position_update.emit, self.update_limits)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self):
|
||||
"""Property to set the device"""
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button.setVisible(not value)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device = positioner
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update.emit,
|
||||
self.update_limits,
|
||||
self.on_device_readback,
|
||||
self._device_ui_components(new_device),
|
||||
)
|
||||
|
||||
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents:
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget,
|
||||
"position_indicator": self.ui.position_indicator,
|
||||
"readback": self.ui.readback,
|
||||
"setpoint": self.ui.setpoint,
|
||||
"step_size": self.ui.step_size,
|
||||
"device_box": self.ui.device_box,
|
||||
"stop": self.ui.stop,
|
||||
"tweak_increase": self.ui.tweak_right,
|
||||
"tweak_decrease": self.ui.tweak_left,
|
||||
}
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device,
|
||||
self._device_ui_components(self.device),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update.emit,
|
||||
self.update_limits,
|
||||
)
|
||||
|
||||
def update_limits(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits:
|
||||
return
|
||||
self._limits = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
self._stop_device(self.device)
|
||||
|
||||
@property
|
||||
def step_size(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size.value()
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_right(self):
|
||||
"""Tweak motor right"""
|
||||
self.dev[self.device].move(self.step_size, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_left(self):
|
||||
"""Tweak motor left"""
|
||||
self.dev[self.device].move(-self.step_size, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change(self):
|
||||
"""Change the setpoint for the motor"""
|
||||
self.ui.setpoint.clearFocus()
|
||||
setpoint = self.ui.setpoint.text()
|
||||
self.dev[self.device].move(float(setpoint), relative=False)
|
||||
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
|
||||
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box_plugin import (
|
||||
PositionerBoxPlugin,
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['positioner_box_2d.py']}
|
||||
@@ -1,43 +1,41 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
|
||||
PositionerBox2D,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECWaveformWidget' name='bec_waveform_widget'>
|
||||
<widget class='PositionerBox2D' name='positioner_box2_d'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECWaveformWidget(parent)
|
||||
t = PositionerBox2D(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Plots"
|
||||
return "Device Control"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECWaveformWidget.ICON_NAME)
|
||||
return designer_material_icon(PositionerBox2D.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_waveform_widget"
|
||||
return "positioner_box2_d"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -49,10 +47,10 @@ class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECWaveformWidget"
|
||||
return "PositionerBox2D"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECWaveformWidget"
|
||||
return "Simple Widget to control two positioners in box form"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,482 @@
|
||||
""" Module for a PositionerBox2D widget to control two positioner devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
DeviceId = Literal["horizontal", "vertical"]
|
||||
|
||||
|
||||
class PositionerBox2D(PositionerBoxBase):
|
||||
"""Simple Widget to control two positioners in box form"""
|
||||
|
||||
ui_file = "positioner_box_2d.ui"
|
||||
|
||||
PLUGIN = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
# Signals emitted to inform listeners about a position update
|
||||
position_update_hor = Signal(float)
|
||||
position_update_ver = Signal(float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
device_hor: Positioner | str | None = None,
|
||||
device_ver: Positioner | str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device_hor (Positioner | str): The first device to control - assigned the horizontal axis.
|
||||
device_ver (Positioner | str): The second device to control - assigned the vertical axis.
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
self._device_hor = ""
|
||||
self._device_ver = ""
|
||||
self._limits_hor = None
|
||||
self._limits_ver = None
|
||||
self._dialog = None
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self.init_ui()
|
||||
self.device_hor = device_hor
|
||||
self.device_ver = device_ver
|
||||
|
||||
self.connect_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed_hor.connect(self.on_device_change_hor)
|
||||
self.device_changed_ver.connect(self.on_device_change_ver)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
self.setpoint_validator_hor = QDoubleValidator()
|
||||
self.setpoint_validator_ver = QDoubleValidator()
|
||||
|
||||
def connect_ui(self):
|
||||
"""Connect the UI components to signals, data, or routines"""
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
||||
ui = self._device_ui_components_hv(device_id)
|
||||
tweak_inc = (
|
||||
self.on_tweak_inc_hor if device_id == "horizontal" else self.on_tweak_inc_ver
|
||||
)
|
||||
tweak_dec = (
|
||||
self.on_tweak_dec_hor if device_id == "horizontal" else self.on_tweak_dec_ver
|
||||
)
|
||||
ui["setpoint"].setValidator(val)
|
||||
ui["setpoint"].returnPressed.connect(
|
||||
self.on_setpoint_change_hor
|
||||
if device_id == "horizontal"
|
||||
else self.on_setpoint_change_ver
|
||||
)
|
||||
ui["stop"].setToolTip("Stop")
|
||||
ui["step_size"].setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType)
|
||||
ui["tweak_increase"].clicked.connect(tweak_inc)
|
||||
ui["tweak_decrease"].clicked.connect(tweak_dec)
|
||||
|
||||
_init_ui(self.setpoint_validator_hor, "horizontal")
|
||||
_init_ui(self.setpoint_validator_ver, "vertical")
|
||||
|
||||
self.ui.stop_button.button.clicked.connect(self.on_stop)
|
||||
|
||||
self.ui.step_decrease_hor.clicked.connect(self.on_step_dec_hor)
|
||||
self.ui.step_decrease_ver.clicked.connect(self.on_step_dec_ver)
|
||||
self.ui.step_increase_hor.clicked.connect(self.on_step_inc_hor)
|
||||
self.ui.step_increase_ver.clicked.connect(self.on_step_inc_ver)
|
||||
|
||||
self.ui.tool_button_hor.clicked.connect(
|
||||
self._open_dialog_selection(self.set_positioner_hor)
|
||||
)
|
||||
self.ui.tool_button_ver.clicked.connect(
|
||||
self._open_dialog_selection(self.set_positioner_ver)
|
||||
)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button_hor.setIcon(icon)
|
||||
self.ui.tool_button_ver.setIcon(icon)
|
||||
|
||||
step_tooltip = "Step by the step size"
|
||||
tweak_tooltip = "Tweak by 1/10th the step size"
|
||||
|
||||
for b in [
|
||||
self.ui.step_increase_hor,
|
||||
self.ui.step_increase_ver,
|
||||
self.ui.step_decrease_hor,
|
||||
self.ui.step_decrease_ver,
|
||||
]:
|
||||
b.setToolTip(step_tooltip)
|
||||
|
||||
for b in [
|
||||
self.ui.tweak_increase_hor,
|
||||
self.ui.tweak_increase_ver,
|
||||
self.ui.tweak_decrease_hor,
|
||||
self.ui.tweak_decrease_ver,
|
||||
]:
|
||||
b.setToolTip(tweak_tooltip)
|
||||
|
||||
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
|
||||
self.ui.tweak_increase_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_right", **icon_options)
|
||||
)
|
||||
self.ui.step_increase_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_right", **icon_options)
|
||||
)
|
||||
self.ui.tweak_decrease_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_left", **icon_options)
|
||||
)
|
||||
self.ui.step_decrease_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_left", **icon_options)
|
||||
)
|
||||
self.ui.tweak_increase_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_up", **icon_options)
|
||||
)
|
||||
self.ui.step_increase_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_up", **icon_options)
|
||||
)
|
||||
self.ui.tweak_decrease_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_down", **icon_options)
|
||||
)
|
||||
self.ui.step_decrease_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_down", **icon_options)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_hor(self):
|
||||
"""SafeProperty to set the device"""
|
||||
return self._device_hor
|
||||
|
||||
@device_hor.setter
|
||||
def device_hor(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
if value == self.device_ver:
|
||||
return
|
||||
old_device = self._device_hor
|
||||
self._device_hor = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_hor.emit(old_device, value)
|
||||
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_ver(self):
|
||||
"""SafeProperty to set the device"""
|
||||
return self._device_ver
|
||||
|
||||
@device_ver.setter
|
||||
def device_ver(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
if value == self.device_hor:
|
||||
return
|
||||
old_device = self._device_ver
|
||||
self._device_ver = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_ver.emit(old_device, value)
|
||||
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button_hor.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button_hor.setVisible(not value)
|
||||
self.ui.tool_button_ver.setVisible(not value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_boxes(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.device_box_hor.isVisible()
|
||||
|
||||
@hide_device_boxes.setter
|
||||
def hide_device_boxes(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.device_box_hor.setVisible(not value)
|
||||
self.ui.device_box_ver.setVisible(not value)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner_hor(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device_hor = positioner
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner_ver(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device_ver = positioner
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_hor(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update_hor.emit,
|
||||
self.update_limits_hor,
|
||||
self.on_device_readback_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_ver(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update_ver.emit,
|
||||
self.update_limits_ver,
|
||||
self.on_device_readback_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
)
|
||||
|
||||
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
|
||||
if device == "horizontal":
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget_hor,
|
||||
"position_indicator": self.ui.position_indicator_hor,
|
||||
"readback": self.ui.readback_hor,
|
||||
"setpoint": self.ui.setpoint_hor,
|
||||
"step_size": self.ui.step_size_hor,
|
||||
"device_box": self.ui.device_box_hor,
|
||||
"stop": self.ui.stop_button,
|
||||
"tweak_increase": self.ui.tweak_increase_hor,
|
||||
"tweak_decrease": self.ui.tweak_decrease_hor,
|
||||
}
|
||||
elif device == "vertical":
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget_ver,
|
||||
"position_indicator": self.ui.position_indicator_ver,
|
||||
"readback": self.ui.readback_ver,
|
||||
"setpoint": self.ui.setpoint_ver,
|
||||
"step_size": self.ui.step_size_ver,
|
||||
"device_box": self.ui.device_box_ver,
|
||||
"stop": self.ui.stop_button,
|
||||
"tweak_increase": self.ui.tweak_increase_ver,
|
||||
"tweak_decrease": self.ui.tweak_decrease_ver,
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Device {device} is not represented by this UI")
|
||||
|
||||
def _device_ui_components(self, device: str):
|
||||
if device == self.device_hor:
|
||||
return self._device_ui_components_hv("horizontal")
|
||||
if device == self.device_ver:
|
||||
return self._device_ui_components_hv("vertical")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update_hor.emit,
|
||||
self.update_limits_hor,
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_ver(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update_ver.emit,
|
||||
self.update_limits_ver,
|
||||
)
|
||||
|
||||
def update_limits_hor(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits_hor:
|
||||
return
|
||||
self._limits_hor = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator_hor, self.setpoint_validator_hor)
|
||||
|
||||
def update_limits_ver(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits_ver:
|
||||
return
|
||||
self._limits_ver = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator_ver, self.setpoint_validator_ver)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
self._stop_device(f"{self.device_hor} or {self.device_ver}")
|
||||
|
||||
@SafeProperty(float)
|
||||
def step_size_hor(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size_hor.value()
|
||||
|
||||
@step_size_hor.setter
|
||||
def step_size_hor(self, val: float):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_hor.setValue(val)
|
||||
|
||||
@SafeProperty(float)
|
||||
def step_size_ver(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size_ver.value()
|
||||
|
||||
@step_size_ver.setter
|
||||
def step_size_ver(self, val: float):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_ver.setValue(val)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_hor].move(self.step_size_hor / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_dec_hor(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_hor].move(-self.step_size_hor / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_hor].move(self.step_size_hor, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_dec_hor(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_hor].move(-self.step_size_hor, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_ver(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_ver].move(self.step_size_ver / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_dec_ver(self):
|
||||
"""Tweak device b down"""
|
||||
self.dev[self.device_ver].move(-self.step_size_ver / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_inc_ver(self):
|
||||
"""Tweak device b up"""
|
||||
self.dev[self.device_ver].move(self.step_size_ver, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_dec_ver(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_ver].move(-self.step_size_ver, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change_hor(self):
|
||||
"""Change the setpoint for device a"""
|
||||
self.ui.setpoint_hor.clearFocus()
|
||||
setpoint = self.ui.setpoint_hor.text()
|
||||
self.dev[self.device_hor].move(float(setpoint), relative=False)
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change_ver(self):
|
||||
"""Change the setpoint for device b"""
|
||||
self.ui.setpoint_ver.clearFocus()
|
||||
setpoint = self.ui.setpoint_ver.text()
|
||||
self.dev[self.device_ver].move(float(setpoint), relative=False)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox2D()
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,562 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>326</width>
|
||||
<height>323</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="0" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="2">
|
||||
<widget class="QGroupBox" name="device_box_ver">
|
||||
<property name="title">
|
||||
<string>No positioner selected</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="QToolButton" name="tool_button_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="readback_ver">
|
||||
<property name="text">
|
||||
<string>Position</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="SpinnerWidget" name="spinner_widget_ver">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLineEdit" name="setpoint_ver">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="step_size_ver">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QGroupBox" name="device_box_hor">
|
||||
<property name="title">
|
||||
<string>No positioner selected</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5" rowstretch="0,0,0,0,0,0,0,0,0,0,0">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="9" column="0">
|
||||
<widget class="QLineEdit" name="setpoint_hor">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<item>
|
||||
<widget class="QToolButton" name="tool_button_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="readback_hor">
|
||||
<property name="text">
|
||||
<string>Position</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="SpinnerWidget" name="spinner_widget_hor">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="10" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_7">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="step_size_hor">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="PositionIndicator" name="position_indicator_ver">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum" stdset="0">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="vertical" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="value" stdset="0">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="indicator_width" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="rounded_corners" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="step_increase_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="5">
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="3">
|
||||
<widget class="QPushButton" name="step_decrease_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="5">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="2">
|
||||
<spacer name="horizontalSpacer_16">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="horizontalSpacer_14">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<spacer name="horizontalSpacer_17">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="1">
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="5">
|
||||
<widget class="QPushButton" name="step_increase_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="4">
|
||||
<spacer name="horizontalSpacer_10">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="tweak_decrease_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="horizontalSpacer_15">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<spacer name="horizontalSpacer_35">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="tweak_decrease_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="step_decrease_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="4">
|
||||
<widget class="QPushButton" name="tweak_increase_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="tweak_increase_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_12">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="4">
|
||||
<spacer name="horizontalSpacer_11">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="5">
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="5">
|
||||
<widget class="StopButton" name="stop_button"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="PositionIndicator" name="position_indicator_hor">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum" stdset="0">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="value" stdset="0">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="indicator_width" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="rounded_corners" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionIndicator</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>position_indicator</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SpinnerWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>spinner_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>tool_button_hor</tabstop>
|
||||
<tabstop>tool_button_ver</tabstop>
|
||||
<tabstop>setpoint_hor</tabstop>
|
||||
<tabstop>setpoint_ver</tabstop>
|
||||
<tabstop>step_size_hor</tabstop>
|
||||
<tabstop>step_size_ver</tabstop>
|
||||
<tabstop>tweak_decrease_hor</tabstop>
|
||||
<tabstop>tweak_increase_ver</tabstop>
|
||||
<tabstop>tweak_increase_hor</tabstop>
|
||||
<tabstop>tweak_decrease_ver</tabstop>
|
||||
<tabstop>step_decrease_hor</tabstop>
|
||||
<tabstop>step_increase_ver</tabstop>
|
||||
<tabstop>step_increase_hor</tabstop>
|
||||
<tabstop>step_decrease_ver</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -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.control.device_control.positioner_box.positioner_box_2d.positioner_box2_d_plugin import (
|
||||
PositionerBox2DPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBox2DPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
|
||||
|
||||
class PositionerControlLine(PositionerBox):
|
||||
@@ -12,13 +14,14 @@ class PositionerControlLine(PositionerBox):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "switch_left"
|
||||
|
||||
def __init__(self, parent=None, device: Positioner = None, *args, **kwargs):
|
||||
def __init__(self, parent=None, device: Positioner | str | None = None, *args, **kwargs):
|
||||
"""Initialize the DeviceControlLine.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
super().__init__(parent=parent, device=device, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import os
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line_plugin import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line_plugin import (
|
||||
PositionerControlLinePlugin,
|
||||
)
|
||||
|
||||
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, QSize, Signal, Slot
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -32,7 +33,7 @@ class PositionerGroupBox(QGroupBox):
|
||||
self.widget.position_update.connect(self._on_position_update)
|
||||
self.widget.expand.connect(self._on_expand)
|
||||
self.setTitle(self.device_name)
|
||||
self.widget.init_device() # force readback
|
||||
self.widget.force_update_readback()
|
||||
|
||||
def _on_expand(self, expand):
|
||||
if expand:
|
||||
@@ -82,7 +83,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
def minimumSizeHint(self):
|
||||
return QSize(300, 30)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_positioners(self, device_names: str):
|
||||
"""Redraw grid with positioners from device_names string
|
||||
|
||||
@@ -130,7 +131,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
widget = self.sender()
|
||||
self.device_position_update.emit(widget.title(), pos)
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def devices_list(self):
|
||||
"""Device names string separated by space"""
|
||||
return " ".join(self._device_widgets)
|
||||
@@ -144,7 +145,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
return
|
||||
self.set_positioners(device_names)
|
||||
|
||||
@Property(int)
|
||||
@SafeProperty(int)
|
||||
def grid_max_cols(self):
|
||||
"""Max number of columns for widgets grid"""
|
||||
return self._grid_ncols
|
||||
|
||||
@@ -59,7 +59,7 @@ class DeviceInputBase(BECWidget):
|
||||
ReadoutPriority.ON_REQUEST: "readout_on_request",
|
||||
}
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id: str = None):
|
||||
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
|
||||
|
||||
if config is None:
|
||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
||||
@@ -67,7 +67,7 @@ class DeviceInputBase(BECWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DeviceInputConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._device_filter = []
|
||||
self._readout_filter = []
|
||||
|
||||
@@ -35,14 +35,14 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Kind.config: "include_config_signals",
|
||||
}
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id: str = None):
|
||||
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
|
||||
if config is None:
|
||||
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceSignalInputBaseConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -45,8 +45,9 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
|
||||
@@ -48,11 +48,12 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
self.completer = QCompleter(self)
|
||||
self.setCompleter(self.completer)
|
||||
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox_plugin import (
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combo_box_plugin import (
|
||||
SignalComboBoxPlugin,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='SignalComboBox' name='signal_combobox'>
|
||||
<widget class='SignalComboBox' name='signal_combo_box'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return designer_material_icon(SignalComboBox.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "signal_combobox"
|
||||
return "signal_combo_box"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -48,7 +48,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "SignalComboBox"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "Signal ComboBox Example for BEC Widgets with autocomplete."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -38,8 +38,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
|
||||
@@ -39,9 +39,10 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._is_valid_input = False
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.completer = QCompleter(self)
|
||||
|
||||
@@ -50,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "SignalLineEdit"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "Signal LineEdit Example for BEC Widgets with autocomplete."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
from types import NoneType, SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -18,12 +18,13 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
@@ -42,6 +43,7 @@ class ScanControlConfig(ConnectionConfig):
|
||||
class ScanControl(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
ARG_BOX_POSITION: int = 2
|
||||
|
||||
scan_started = Signal()
|
||||
scan_selected = Signal(str)
|
||||
@@ -56,13 +58,14 @@ class ScanControl(BECWidget, QWidget):
|
||||
gui_id: str | None = None,
|
||||
allowed_scans: list | None = None,
|
||||
default_scan: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
if config is None:
|
||||
config = ScanControlConfig(
|
||||
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
|
||||
)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self._hide_add_remove_buttons = False
|
||||
@@ -83,6 +86,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.config.default_scan = default_scan
|
||||
self.config.allowed_scans = allowed_scans
|
||||
|
||||
self._scan_metadata: dict | None = None
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
|
||||
@@ -152,6 +157,20 @@ class ScanControl(BECWidget, QWidget):
|
||||
# Initialize scan selection
|
||||
self.populate_scans()
|
||||
|
||||
# Append metadata form
|
||||
self._add_metadata_form()
|
||||
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
self._metadata_form = ScanMetadata()
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.validate_form()
|
||||
|
||||
def populate_scans(self):
|
||||
"""Populates the scan selection combo box with available scans from BEC session."""
|
||||
self.available_scans = self.client.connector.get(
|
||||
@@ -176,8 +195,9 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.request_last_executed_scan_parameters()
|
||||
self.restore_scan_parameters(selected_scan_name)
|
||||
|
||||
@Slot()
|
||||
def request_last_executed_scan_parameters(self):
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def request_last_executed_scan_parameters(self, *_):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
@@ -211,7 +231,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def current_scan(self):
|
||||
"""Returns the scan name for the currently selected scan."""
|
||||
return self.comboBox_scan_selection.currentText()
|
||||
@@ -227,7 +247,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
return
|
||||
self.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_current_scan(self, scan_name: str):
|
||||
"""Slot for setting the current scan to the given scan name.
|
||||
|
||||
@@ -236,7 +256,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.current_scan = scan_name
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_arg_box(self):
|
||||
"""Property to hide the argument box."""
|
||||
if self.arg_box is None:
|
||||
@@ -253,7 +273,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
if self.arg_box is not None:
|
||||
self.arg_box.setVisible(not hide)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_kwarg_boxes(self):
|
||||
"""Property to hide the keyword argument boxes."""
|
||||
if len(self.kwarg_boxes) == 0:
|
||||
@@ -274,7 +294,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
for box in self.kwarg_boxes:
|
||||
box.setVisible(not hide)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_scan_control_buttons(self):
|
||||
"""Property to hide the scan control buttons."""
|
||||
return not self.button_run_scan.isVisible()
|
||||
@@ -288,12 +308,40 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.show_scan_control_buttons(not hide)
|
||||
|
||||
@Slot(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_metadata(self):
|
||||
"""Property to hide the metadata form."""
|
||||
return not self._metadata_form.isVisible()
|
||||
|
||||
@hide_metadata.setter
|
||||
def hide_metadata(self, hide: bool):
|
||||
"""Setter for the hide_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the metadata form.
|
||||
"""
|
||||
self._metadata_form.setVisible(not hide)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_optional_metadata(self):
|
||||
"""Property to hide the optional metadata form."""
|
||||
return self._metadata_form.hide_optional_metadata
|
||||
|
||||
@hide_optional_metadata.setter
|
||||
def hide_optional_metadata(self, hide: bool):
|
||||
"""Setter for the hide_optional_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the optional metadata form.
|
||||
"""
|
||||
self._metadata_form.hide_optional_metadata = hide
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_scan_control_buttons(self, show: bool):
|
||||
"""Shows or hides the scan control buttons."""
|
||||
self.scan_control_group.setVisible(show)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_scan_selection_combobox(self):
|
||||
"""Property to hide the scan selection combobox."""
|
||||
return not self.comboBox_scan_selection.isVisible()
|
||||
@@ -307,12 +355,12 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.show_scan_selection_combobox(not hide)
|
||||
|
||||
@Slot(bool)
|
||||
@SafeSlot(bool)
|
||||
def show_scan_selection_combobox(self, show: bool):
|
||||
"""Shows or hides the scan selection combobox."""
|
||||
self.scan_selection_group.setVisible(show)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def scan_select(self, scan_name: str):
|
||||
"""
|
||||
Slot for scan selection. Updates the scan control layout based on the selected scan.
|
||||
@@ -335,7 +383,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_add_remove_buttons(self):
|
||||
"""Property to hide the add_remove buttons."""
|
||||
return self._hide_add_remove_buttons
|
||||
@@ -358,10 +406,11 @@ class ScanControl(BECWidget, QWidget):
|
||||
Args:
|
||||
groups(list): List of dictionaries containing the gui_group information.
|
||||
"""
|
||||
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(box)
|
||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||
self.kwarg_boxes.append(box)
|
||||
|
||||
def add_arg_group(self, group: dict):
|
||||
@@ -374,9 +423,9 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||
self.layout.addWidget(self.arg_box)
|
||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def emit_device_selected(self, dev_names):
|
||||
"""
|
||||
Emit the signal to inform about selected device(s)
|
||||
@@ -454,10 +503,20 @@ class ScanControl(BECWidget, QWidget):
|
||||
scan_params = ScanParameterConfig(name=scan_name, args=args, kwargs=kwargs)
|
||||
self.config.scans[scan_name] = scan_params
|
||||
|
||||
@SafeSlot(dict)
|
||||
@SafeSlot(NoneType)
|
||||
def update_scan_metadata(self, md: dict | None):
|
||||
self._scan_metadata = md
|
||||
if md is None:
|
||||
self.button_run_scan.setEnabled(False)
|
||||
else:
|
||||
self.button_run_scan.setEnabled(True)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def run_scan(self):
|
||||
"""Starts the selected scan with the given parameters."""
|
||||
args, kwargs = self.get_scan_parameters()
|
||||
kwargs["metadata"] = self._scan_metadata
|
||||
self.scan_args.emit(args)
|
||||
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
|
||||
if callable(scan_function):
|
||||
|
||||
@@ -37,9 +37,14 @@ class DapComboBox(BECWidget, QWidget):
|
||||
fit_model_updated = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self, parent=None, client=None, gui_id: str | None = None, default_fit: str | None = None
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
default_fit: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
super().__init__(client=client, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.fit_model_combobox = QComboBox(self)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
@@ -29,6 +30,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
target_widget=None,
|
||||
gui_id: str | None = None,
|
||||
ui_file="lmfit_dialog_vertical.ui",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialises the LMFitDialog widget.
|
||||
@@ -41,8 +43,10 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
gui_id (str): GUI ID.
|
||||
ui_file (str): The UI file to be loaded.
|
||||
"""
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("LMFitDialog")
|
||||
self._ui_file = ui_file
|
||||
self.target_widget = target_widget
|
||||
|
||||
@@ -65,7 +69,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
|
||||
@property
|
||||
def enable_actions(self) -> bool:
|
||||
"""Property to enable the move to buttons."""
|
||||
"""SafeProperty to enable the move to buttons."""
|
||||
return self._enable_actions
|
||||
|
||||
@enable_actions.setter
|
||||
@@ -74,37 +78,37 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
for button in self.action_buttons.values():
|
||||
button.setEnabled(enable)
|
||||
|
||||
@Property(list)
|
||||
@SafeProperty(list)
|
||||
def active_action_list(self) -> list[str]:
|
||||
"""Property to list the names of the fit parameters for which actions should be enabled."""
|
||||
"""SafeProperty to list the names of the fit parameters for which actions should be enabled."""
|
||||
return self._active_actions
|
||||
|
||||
@active_action_list.setter
|
||||
def active_action_list(self, actions: list[str]):
|
||||
self._active_actions = actions
|
||||
|
||||
# This slot needed?
|
||||
@Slot(bool)
|
||||
# This SafeSlot needed?
|
||||
@SafeSlot(bool)
|
||||
def set_actions_enabled(self, enable: bool) -> bool:
|
||||
"""Slot to enable the move to buttons.
|
||||
"""SafeSlot to enable the move to buttons.
|
||||
|
||||
Args:
|
||||
enable (bool): Whether to enable the action buttons.
|
||||
"""
|
||||
self.enable_actions = enable
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def always_show_latest(self):
|
||||
"""Property to indicate if always the latest DAP update is displayed."""
|
||||
"""SafeProperty to indicate if always the latest DAP update is displayed."""
|
||||
return self._always_show_latest
|
||||
|
||||
@always_show_latest.setter
|
||||
def always_show_latest(self, show: bool):
|
||||
self._always_show_latest = show
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_curve_selection(self):
|
||||
"""Property for showing the curve selection."""
|
||||
"""SafeProperty for showing the curve selection."""
|
||||
return not self.ui.group_curve_selection.isVisible()
|
||||
|
||||
@hide_curve_selection.setter
|
||||
@@ -116,9 +120,9 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
"""
|
||||
self.ui.group_curve_selection.setVisible(not show)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_summary(self) -> bool:
|
||||
"""Property for showing the summary."""
|
||||
"""SafeProperty for showing the summary."""
|
||||
return not self.ui.group_summary.isVisible()
|
||||
|
||||
@hide_summary.setter
|
||||
@@ -130,9 +134,9 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
"""
|
||||
self.ui.group_summary.setVisible(not show)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_parameters(self) -> bool:
|
||||
"""Property for showing the parameters."""
|
||||
"""SafeProperty for showing the parameters."""
|
||||
return not self.ui.group_parameters.isVisible()
|
||||
|
||||
@hide_parameters.setter
|
||||
@@ -146,7 +150,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
|
||||
@property
|
||||
def fit_curve_id(self) -> str:
|
||||
"""Property for the currently displayed fit curve_id."""
|
||||
"""SafeProperty for the currently displayed fit curve_id."""
|
||||
return self._fit_curve_id
|
||||
|
||||
@fit_curve_id.setter
|
||||
@@ -159,7 +163,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self._fit_curve_id = curve_id
|
||||
self.selected_fit.emit(curve_id)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def remove_dap_data(self, curve_id: str):
|
||||
"""Remove the DAP data for the given curve_id.
|
||||
|
||||
@@ -169,7 +173,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self.summary_data.pop(curve_id, None)
|
||||
self.refresh_curve_list()
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def select_curve(self, curve_id: str):
|
||||
"""Select active curve_id in the curve list.
|
||||
|
||||
@@ -178,7 +182,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
"""
|
||||
self.fit_curve_id = curve_id
|
||||
|
||||
@Slot(dict, dict)
|
||||
@SafeSlot(dict, dict)
|
||||
def update_summary_tree(self, data: dict, metadata: dict):
|
||||
"""Update the summary tree with the given data.
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>303</width>
|
||||
<height>457</height>
|
||||
<width>337</width>
|
||||
<height>552</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -35,11 +35,17 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_curve_selection">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>100</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Select Curve</string>
|
||||
</property>
|
||||
@@ -60,7 +66,7 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_summary">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -68,7 +74,7 @@
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
@@ -113,7 +119,7 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_parameters">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -121,7 +127,7 @@
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
|
||||
@@ -16,7 +16,6 @@ import sys
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from bec_lib.logger import bec_logger
|
||||
from pygments.token import Token
|
||||
from pyte.screens import History
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
@@ -28,8 +27,6 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
@@ -364,24 +361,6 @@ class BECConsole(QtWidgets.QWidget):
|
||||
def send_ctrl_c(self, timeout=None):
|
||||
self.term.send_ctrl_c(timeout)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the terminal"""
|
||||
self.execute_command("\x03") # Ctrl-C
|
||||
self.execute_command("exit()")
|
||||
timeout = 5
|
||||
interval = 0.1
|
||||
timer = 0
|
||||
# os.close(self.term.fd)
|
||||
while self.term.fd is not None:
|
||||
time.sleep(interval)
|
||||
timer += interval
|
||||
if timer > 0.8 * timeout:
|
||||
logger.warning(f"Terminal still cleaning up after {timer:.1f} seconds")
|
||||
if timer > timeout:
|
||||
logger.error(f"Terminal cleanup timed out after {timeout} seconds")
|
||||
break
|
||||
self.deleteLater()
|
||||
|
||||
cols = pyqtProperty(int, get_cols, set_cols)
|
||||
rows = pyqtProperty(int, get_rows, set_rows)
|
||||
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
|
||||
|
||||
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal file
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
|
||||
AdditionalMetadataTable,
|
||||
AdditionalMetadataTableModel,
|
||||
)
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
|
||||
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]
|
||||
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal file
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Callable, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
class MetadataWidget(QWidget):
|
||||
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._info = info
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._info)
|
||||
self._desc = self._info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
if clearable_required(info):
|
||||
self._add_clear_button()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self): ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
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
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
self._main_widget.setText(value)
|
||||
|
||||
|
||||
class IntMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
precision = field_precision(self._info)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class BoolMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
|
||||
if annotation in [str, str | None]:
|
||||
return StrMetadataField
|
||||
if annotation in [int, int | None]:
|
||||
return IntMetadataField
|
||||
if annotation in [float, float | None, Decimal, Decimal | None]:
|
||||
return FloatDecimalMetadataField
|
||||
if annotation in [bool, bool | None]:
|
||||
return BoolMetadataField
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal file
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from math import inf, nextafter
|
||||
from typing import TYPE_CHECKING, TypeVar, get_args
|
||||
|
||||
from annotated_types import Ge, Gt, Le, Lt
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
_MININT = -2147483648
|
||||
_MAXINT = 2147483647
|
||||
_MINFLOAT = -sys.float_info.max
|
||||
_MAXFLOAT = sys.float_info.max
|
||||
|
||||
T = TypeVar("T", int, float, Decimal)
|
||||
|
||||
|
||||
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
|
||||
_min = _MININT if type_ is int else _MINFLOAT
|
||||
_max = _MAXINT if type_ is int else _MAXFLOAT
|
||||
for md in info.metadata:
|
||||
if isinstance(md, Ge):
|
||||
_min = type_(md.ge) # type: ignore
|
||||
if isinstance(md, Gt):
|
||||
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
|
||||
if isinstance(md, Lt):
|
||||
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
|
||||
if isinstance(md, Le):
|
||||
_max = type_(md.le) # type: ignore
|
||||
return _min, _max # type: ignore
|
||||
|
||||
|
||||
def _get_anno(info: FieldInfo, annotation: str, default):
|
||||
for md in info.metadata:
|
||||
if hasattr(md, annotation):
|
||||
return getattr(md, annotation)
|
||||
return default
|
||||
|
||||
|
||||
def field_precision(info: FieldInfo):
|
||||
return _get_anno(info, "decimal_places", 307)
|
||||
|
||||
|
||||
def field_maxlen(info: FieldInfo):
|
||||
return _get_anno(info, "max_length", None)
|
||||
|
||||
|
||||
def field_minlen(info: FieldInfo):
|
||||
return _get_anno(info, "min_length", None)
|
||||
|
||||
|
||||
def field_default(info: FieldInfo):
|
||||
if info.default is PydanticUndefined:
|
||||
return
|
||||
return info.default
|
||||
|
||||
|
||||
def clearable_required(info: FieldInfo):
|
||||
return type(None) in get_args(info.annotation) or info.is_required()
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTreeView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class AdditionalMetadataTableModel(QAbstractTableModel):
|
||||
def __init__(self, data):
|
||||
super().__init__()
|
||||
self._data: list[list[str]] = data
|
||||
self._disallowed_keys: list[str] = []
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
|
||||
) -> Any:
|
||||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||||
return "Key" if section == 0 else "Value"
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
def rowCount(self, index: QModelIndex = QModelIndex()):
|
||||
return 0 if index.isValid() else len(self._data)
|
||||
|
||||
def columnCount(self, index: QModelIndex = QModelIndex()):
|
||||
return 0 if index.isValid() else 2
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole):
|
||||
if index.isValid():
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
if value in self._disallowed_keys or value in self._other_keys(index.row()):
|
||||
return False
|
||||
self._data[index.row()][index.column()] = str(value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
self._disallowed_keys = keys
|
||||
for i, item in enumerate(self._data):
|
||||
if item[0] in self._disallowed_keys:
|
||||
self._data[i][0] = ""
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
|
||||
|
||||
def _other_keys(self, row: int):
|
||||
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
||||
|
||||
def flags(self, _):
|
||||
return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
|
||||
|
||||
def insertRows(self, row, number, index):
|
||||
"""We only support adding one at a time for now"""
|
||||
if row != self.rowCount() or number != 1:
|
||||
return False
|
||||
self.beginInsertRows(QModelIndex(), 0, 0)
|
||||
self._data.append(["", ""])
|
||||
self.endInsertRows()
|
||||
return True
|
||||
|
||||
def removeRows(self, row, number, index):
|
||||
"""This can only be consecutive, so instead of trying to be clever, only support removing one at a time"""
|
||||
if number != 1:
|
||||
return False
|
||||
self.beginRemoveRows(QModelIndex(), row, row)
|
||||
del self._data[row]
|
||||
self.endRemoveRows()
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def add_row(self):
|
||||
self.insertRow(self.rowCount())
|
||||
|
||||
@SafeSlot(list)
|
||||
def delete_rows(self, rows: list[int]):
|
||||
# delete from the end so indices stay correct
|
||||
for row in sorted(rows, reverse=True):
|
||||
self.removeRows(row, 1, QModelIndex())
|
||||
|
||||
def dump_dict(self):
|
||||
if self._data == [[]]:
|
||||
return {}
|
||||
return dict(self._data)
|
||||
|
||||
|
||||
class AdditionalMetadataTable(QWidget):
|
||||
|
||||
delete_rows = Signal(list)
|
||||
|
||||
def __init__(self, initial_data: list[list[str]]):
|
||||
super().__init__()
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._table_model = AdditionalMetadataTableModel(initial_data)
|
||||
self._table_view = QTreeView()
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._table_view.setSizePolicy(
|
||||
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._buttons = QVBoxLayout()
|
||||
self._layout.addLayout(self._buttons)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setToolTip("delete rows containing any selected cells")
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
self._add_button.clicked.connect(self._table_model.add_row)
|
||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||
self.delete_rows.connect(self._table_model.delete_rows)
|
||||
|
||||
def delete_selected_rows(self):
|
||||
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
|
||||
row_indices = list({r.row() for r in cells})
|
||||
if row_indices:
|
||||
self.delete_rows.emit(row_indices)
|
||||
|
||||
def dump_dict(self):
|
||||
return self._table_model.dump_dict()
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
self._table_model.update_disallowed_keys(keys)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
|
||||
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
app.exec()
|
||||
234
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal file
234
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.metadata_schema import get_metadata_schema_for_scan
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import Field, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.qt_utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
|
||||
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
|
||||
AdditionalMetadataTable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanMetadata(BECWidget, QWidget):
|
||||
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
|
||||
metadata schema registry supplied in the plugin repo to find pydantic models
|
||||
associated with the scan type. Sets limits for numerical values if specified."""
|
||||
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
scan_name: str | None = None,
|
||||
initial_extras: list[list[str]] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.set_schema(scan_name)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
|
||||
self._layout.addWidget(self._required_md_box)
|
||||
self._required_md_box_layout = QHBoxLayout()
|
||||
self._required_md_box.set_layout(self._required_md_box_layout)
|
||||
|
||||
self._md_grid = QWidget()
|
||||
self._required_md_box_layout.addWidget(self._md_grid)
|
||||
self._grid_container = QVBoxLayout()
|
||||
self._md_grid.setLayout(self._grid_container)
|
||||
self._new_grid_layout()
|
||||
self._grid_container.addLayout(self._md_grid_layout)
|
||||
|
||||
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
|
||||
self._layout.addWidget(self._additional_md_box)
|
||||
self._additional_md_box_layout = QHBoxLayout()
|
||||
self._additional_md_box.set_layout(self._additional_md_box_layout)
|
||||
|
||||
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
|
||||
self.populate()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_with_new_scan(self, scan_name: str):
|
||||
self.set_schema(scan_name)
|
||||
self.populate()
|
||||
self.validate_form()
|
||||
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_full_model_dict()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
|
||||
def get_full_model_dict(self):
|
||||
"""Get the entered metadata as a dict"""
|
||||
return self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
def set_schema(self, scan_name: str | None = None):
|
||||
self._scan_name = scan_name or ""
|
||||
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
self._populate()
|
||||
|
||||
def _populate(self):
|
||||
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
||||
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
|
||||
self._add_griditem(field_name, info, i)
|
||||
|
||||
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
|
||||
grid = self._md_grid_layout
|
||||
label = QLabel(info.title or field_name)
|
||||
label.setProperty("_model_field_name", field_name)
|
||||
label.setToolTip(info.description or field_name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = widget_from_type(info.annotation)(info)
|
||||
widget.valueChanged.connect(self.validate_form)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
|
||||
grid = self._md_grid_layout
|
||||
return {
|
||||
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
|
||||
for i in range(grid.rowCount())
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
while self._md_grid_layout.count():
|
||||
item = self._md_grid_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
self._md_grid_layout.deleteLater()
|
||||
self._new_grid_layout()
|
||||
self._grid_container.addLayout(self._md_grid_layout)
|
||||
self._md_grid.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
self._md_grid_layout = QGridLayout()
|
||||
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_optional_metadata(self): # type: ignore
|
||||
"""Property to hide the optional metadata table."""
|
||||
return not self._additional_md_box.isVisible()
|
||||
|
||||
@hide_optional_metadata.setter
|
||||
def hide_optional_metadata(self, hide: bool):
|
||||
"""Setter for the hide_optional_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the optional metadata table.
|
||||
"""
|
||||
self._additional_md_box.setVisible(not hide)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from unittest.mock import patch
|
||||
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
class ExampleSchema1(BasicScanMetadata):
|
||||
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
||||
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
|
||||
xyz: Decimal = Field(decimal_places=4)
|
||||
baz: bool
|
||||
|
||||
class ExampleSchema2(BasicScanMetadata):
|
||||
checkbox_up_top: bool
|
||||
checkbox_again: bool = Field(
|
||||
title="Checkbox Again", description="this one defaults to True", default=True
|
||||
)
|
||||
different_items: int | None = Field(
|
||||
None, description="This is just one different item...", gt=-100, lt=0
|
||||
)
|
||||
length_limited_string: str = Field(max_length=32)
|
||||
float_with_2dp: Decimal = Field(decimal_places=2)
|
||||
|
||||
class ExampleSchema3(BasicScanMetadata):
|
||||
optional_with_regex: str | None = Field(None, pattern=r"^\d+-\d+$")
|
||||
|
||||
with patch(
|
||||
"bec_lib.metadata_schema._get_metadata_schema_registry",
|
||||
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
|
||||
):
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
selection = QComboBox()
|
||||
selection.addItems(["grid_scan", "scan1", "scan2", "scan3"])
|
||||
|
||||
layout = QVBoxLayout()
|
||||
w.setLayout(layout)
|
||||
|
||||
scan_metadata = ScanMetadata(
|
||||
scan_name="grid_scan",
|
||||
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
|
||||
)
|
||||
selection.currentTextChanged.connect(scan_metadata.update_with_new_scan)
|
||||
|
||||
layout.addWidget(selection)
|
||||
layout.addWidget(scan_metadata)
|
||||
|
||||
set_theme("dark")
|
||||
window = w
|
||||
window.show()
|
||||
app.exec()
|
||||
@@ -5,9 +5,9 @@ from html.parser import HTMLParser
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from qtpy.QtCore import Property, Slot
|
||||
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
@@ -42,14 +42,14 @@ class TextBox(BECWidget, QWidget):
|
||||
USER_ACCESS = ["set_plain_text", "set_html_text"]
|
||||
ICON_NAME = "chat"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
if config is None:
|
||||
config = TextBoxConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = TextBoxConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.text_box_text_edit = QTextEdit(parent=self)
|
||||
@@ -66,7 +66,7 @@ class TextBox(BECWidget, QWidget):
|
||||
else:
|
||||
self.set_html_text(DEFAULT_TEXT)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_plain_text(self, text: str) -> None:
|
||||
"""Set the plain text of the widget.
|
||||
|
||||
@@ -77,7 +77,7 @@ class TextBox(BECWidget, QWidget):
|
||||
self.config.text = text
|
||||
self.config.is_html = False
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_html_text(self, text: str) -> None:
|
||||
"""Set the HTML text of the widget.
|
||||
|
||||
@@ -88,7 +88,7 @@ class TextBox(BECWidget, QWidget):
|
||||
self.config.text = text
|
||||
self.config.is_html = True
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def plain_text(self) -> str:
|
||||
"""Get the text of the widget.
|
||||
|
||||
@@ -106,7 +106,7 @@ class TextBox(BECWidget, QWidget):
|
||||
"""
|
||||
self.set_plain_text(text)
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def html_text(self) -> str:
|
||||
"""Get the HTML text of the widget.
|
||||
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
import glob
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import pathlib
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import bec_lib
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel
|
||||
from pygments.token import Token
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
|
||||
logger = bec_lib.bec_logger.logger
|
||||
|
||||
|
||||
class EnchancedQTreeWidget(QTreeWidget):
|
||||
"""Thin wrapper around QTreeWidget to add some functionality for user scripting"""
|
||||
|
||||
play_button_clicked = Signal(str)
|
||||
edit_button_clicked = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setColumnCount(2)
|
||||
self.setHeaderHidden(True)
|
||||
self.setObjectName(__class__.__name__)
|
||||
self._update_style_sheet()
|
||||
self._icon_size = QSize(24, 24)
|
||||
self.setRootIsDecorated(False)
|
||||
self.setUniformRowHeights(True)
|
||||
self.setWordWrap(True)
|
||||
self.setAnimated(True)
|
||||
self.setIndentation(24)
|
||||
self._adjust_size_policy()
|
||||
|
||||
def _adjust_size_policy(self):
|
||||
"""Adjust the size policy"""
|
||||
header = self.header()
|
||||
header.setMinimumSectionSize(42)
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
|
||||
def _update_style_sheet(self) -> None:
|
||||
"""Update the style sheet"""
|
||||
name = __class__.__name__
|
||||
colors = get_accent_colors()
|
||||
# pylint: disable=protected-access
|
||||
color = colors._palette.midlight().color().name()
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
{name}::item {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
QTreeView::branch:hover {{
|
||||
background: transparent;
|
||||
color: {color};
|
||||
}}
|
||||
{name}::item:hover {{
|
||||
background: {color};
|
||||
}}
|
||||
{name}::item:selected:hover {{
|
||||
background: {color};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def add_top_item(self, label: str) -> QTreeWidgetItem:
|
||||
"""Add a top item to the tree widget
|
||||
|
||||
Args:
|
||||
label (str): The label for the top item
|
||||
|
||||
Returns:
|
||||
QTreeWidgetItem: The top item
|
||||
"""
|
||||
top_item = QTreeWidgetItem(self, [label])
|
||||
top_item.setExpanded(True)
|
||||
top_item.setSelected(False)
|
||||
self.resizeColumnToContents(0)
|
||||
return top_item
|
||||
|
||||
def add_module_item(self, top_item: QTreeWidgetItem, mod_name: str) -> QTreeWidgetItem:
|
||||
"""Add a top item to the tree widget together with an edit button in column 0 and label in 1
|
||||
|
||||
Args:
|
||||
top_item (QTreeWidgetItem): The top item to add the child item to
|
||||
mod_name (str): The label for the child item
|
||||
|
||||
Returns:
|
||||
QTreeWidgetItem: The top item
|
||||
"""
|
||||
child_item = QTreeWidgetItem(top_item)
|
||||
# Add label
|
||||
label = QLabel(mod_name, parent=top_item.treeWidget())
|
||||
# Add edit button with label as parent
|
||||
edit_button = self._create_button(parent=label, button_type="edit")
|
||||
edit_button.clicked.connect(self._handle_edit_button_clicked)
|
||||
self.setItemWidget(child_item, 0, edit_button)
|
||||
self.setItemWidget(child_item, 1, label)
|
||||
self.resizeColumnToContents(0)
|
||||
return child_item
|
||||
|
||||
def add_child_item(self, top_item: QTreeWidgetItem, label: str) -> None:
|
||||
"""Add a child item to the top item together with a play button in column 1
|
||||
|
||||
Args:
|
||||
top_item (QTreeWidgetItem): The top item to add the child item to
|
||||
label (str): The label for the child item
|
||||
|
||||
Returns:
|
||||
QTreeWidgetItem: The child item
|
||||
"""
|
||||
widget = QWidget(top_item.treeWidget())
|
||||
label = QLabel(label)
|
||||
spacer = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.addWidget(label)
|
||||
layout.addItem(spacer)
|
||||
layout.setSpacing(4)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
button = self._create_button(parent=top_item.treeWidget(), button_type="play")
|
||||
button.clicked.connect(self._handle_play_button_clicked)
|
||||
layout.addWidget(button)
|
||||
child_item = QTreeWidgetItem(top_item)
|
||||
self.setItemWidget(child_item, 1, widget)
|
||||
return child_item
|
||||
|
||||
@Slot()
|
||||
def _handle_edit_button_clicked(self):
|
||||
"""Handle the click of the edit button"""
|
||||
button = self.sender()
|
||||
tree_widget_item = self.itemAt(button.pos())
|
||||
text = self.itemWidget(tree_widget_item, 1).text()
|
||||
self.edit_button_clicked.emit(text)
|
||||
|
||||
@Slot()
|
||||
def _handle_play_button_clicked(self):
|
||||
"""Handle the click of the play button"""
|
||||
button = self.sender()
|
||||
widget = button.parent()
|
||||
text = widget.findChild(QLabel).text()
|
||||
self.play_button_clicked.emit(text)
|
||||
|
||||
def _create_button(self, parent: QWidget, button_type: Literal["edit", "play"]) -> QToolButton:
|
||||
"""Create a button for 'edit' or 'play'
|
||||
|
||||
Args:
|
||||
button_type (Literal["edit", "play"]): The type of button to create
|
||||
"""
|
||||
colors = get_accent_colors()
|
||||
if button_type == "edit":
|
||||
color = colors.highlight
|
||||
name = "edit_document"
|
||||
elif button_type == "play":
|
||||
color = colors.success
|
||||
name = "play_arrow"
|
||||
else:
|
||||
raise ValueError("Invalid button type")
|
||||
button = QToolButton(
|
||||
parent=parent,
|
||||
icon=material_icon(
|
||||
name, filled=False, color=color, size=self._icon_size, convert_to_pixmap=False
|
||||
),
|
||||
)
|
||||
button.setContentsMargins(0, 0, 0, 0)
|
||||
button.setStyleSheet("QToolButton { border: none; }")
|
||||
return button
|
||||
|
||||
def _hide_buttons(self, exclude_item: QWidget = None):
|
||||
for button in self.viewport().findChildren(QToolButton):
|
||||
if exclude_item is not None:
|
||||
if button.parent() == exclude_item:
|
||||
continue
|
||||
button.setVisible(False)
|
||||
|
||||
|
||||
class VSCodeDialog(QDialog):
|
||||
"""Dialog for the VSCode editor"""
|
||||
|
||||
def __init__(self, parent=None, client=None, editor: VSCodeEditor = None):
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("VSCode Editor")
|
||||
self.setMinimumWidth(800)
|
||||
self.setMinimumHeight(600)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.editor = editor
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize the UI. Note: this makes the code easier to test."""
|
||||
self.layout.addWidget(self.editor)
|
||||
|
||||
|
||||
class InputDialog(QDialog):
|
||||
"""Dialog for input
|
||||
|
||||
Args:
|
||||
header (str): The header of the dialog
|
||||
info (str): The information of the dialog
|
||||
fields (dict): The fields of the dialog
|
||||
parent (QWidget): The parent widget
|
||||
"""
|
||||
|
||||
def __init__(self, header: str, info: str, fields: dict, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.header = header
|
||||
self.info = info
|
||||
self.fields = fields
|
||||
self._layout = QVBoxLayout(self)
|
||||
self.button_ok = QPushButton(parent=self, text="OK")
|
||||
self.button_cancel = QPushButton(parent=self, text="Cancel")
|
||||
self._init_ui()
|
||||
self.button_ok.clicked.connect(self.accept)
|
||||
self.button_cancel.clicked.connect(self.reject)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the UI"""
|
||||
self.setWindowTitle(f"{self.header}")
|
||||
self.setMinimumWidth(200)
|
||||
box = QGroupBox(self)
|
||||
box.setTitle(self.info)
|
||||
layout = QGridLayout(box)
|
||||
layout.setSpacing(4)
|
||||
layout.setContentsMargins(4, 30, 4, 30)
|
||||
row = 0
|
||||
for name, default in self.fields.items():
|
||||
label = QLabel(parent=self, text=name)
|
||||
line_input = QLineEdit(parent=self)
|
||||
line_input.setObjectName(name)
|
||||
if default is not None:
|
||||
line_input.setText(f"{default}")
|
||||
layout.addWidget(label, row, 0)
|
||||
layout.addWidget(line_input, row, 1)
|
||||
row += 1
|
||||
self._layout.addWidget(box)
|
||||
widget = QWidget(self)
|
||||
sub_layout = QHBoxLayout(widget)
|
||||
sub_layout.addWidget(self.button_ok)
|
||||
sub_layout.addWidget(self.button_cancel)
|
||||
self._layout.addWidget(widget)
|
||||
self.setLayout(self._layout)
|
||||
self.resize(self._layout.sizeHint() * 1.05)
|
||||
|
||||
def get_inputs(self):
|
||||
"""Get the input from the dialog"""
|
||||
out = {}
|
||||
for name, _ in self.fields.items():
|
||||
line_input = self.findChild(QLineEdit, name)
|
||||
if line_input is not None:
|
||||
out[name] = line_input.text()
|
||||
return out
|
||||
|
||||
|
||||
class ScriptBlock(BaseModel):
|
||||
"""Model block for a script"""
|
||||
|
||||
location: Literal["BEC", "USER", "BL"]
|
||||
fname: str
|
||||
module_name: str
|
||||
user_script_name: str | None = None
|
||||
|
||||
|
||||
class UserScriptWidget(BECWidget, QWidget):
|
||||
"""Dialog for displaying the fit summary and params for LMFit DAP processes."""
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = []
|
||||
ICON_NAME = "manage_accounts"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_id: str | None = None,
|
||||
vs_code_editor=None,
|
||||
bec_console=None,
|
||||
):
|
||||
"""
|
||||
Initialize the widget
|
||||
|
||||
Args:
|
||||
parent (QWidget): The parent widget
|
||||
client (BECClient): The BEC client
|
||||
config (dict): The configuration
|
||||
gui_id (str): The GUI ID
|
||||
vs_code_editor (VSCodeEditor): The VSCode editor, dep injection here makes makes testing easier, if None defaults to VSCodeEditor
|
||||
bec_console (BECConsole): The BEC console, note this makes testing easier, if None defaults to BECConsole
|
||||
"""
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.button_new_script = QPushButton(parent=self, text="New Script")
|
||||
self.button_new_script.setObjectName("button_new_script")
|
||||
if vs_code_editor is None:
|
||||
vs_code_editor = VSCodeEditor(parent=self, client=self.client, gui_id=self.gui_id)
|
||||
self._vscode_editor = vs_code_editor
|
||||
if bec_console is None:
|
||||
bec_console = BECConsole(parent=self)
|
||||
self._console = bec_console
|
||||
self.tree_widget = EnchancedQTreeWidget(parent=self)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.user_scripts = defaultdict(lambda: ScriptBlock)
|
||||
self._base_path = os.path.join(str(Path.home()), "bec", "scripts")
|
||||
self._icon_size = QSize(16, 16)
|
||||
self._script_button_register = {}
|
||||
self._code_dialog = None
|
||||
self._script_dialog = None
|
||||
self._new_script_dialog = None
|
||||
|
||||
self.init_ui()
|
||||
self.button_new_script.clicked.connect(self.new_script)
|
||||
self.tree_widget.edit_button_clicked.connect(self.handle_edit_button_clicked)
|
||||
self.tree_widget.play_button_clicked.connect(self.handle_play_button_clicked)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Apply the theme"""
|
||||
self._update_button_ui()
|
||||
self.update_user_scripts()
|
||||
self.tree_widget._update_style_sheet()
|
||||
super().apply_theme(theme)
|
||||
|
||||
def _setup_console(self):
|
||||
"""Setup the console. Toents are needed to allow for the console to check for the prompt during shutdown."""
|
||||
self._console.set_prompt_tokens(
|
||||
(Token.OutPromptNum, "•"),
|
||||
(Token.Prompt, ""), # will match arbitrary string,
|
||||
(Token.Prompt, " ["),
|
||||
(Token.PromptNum, "3"),
|
||||
(Token.Prompt, "/"),
|
||||
(Token.PromptNum, "1"),
|
||||
(Token.Prompt, "] "),
|
||||
(Token.Prompt, "❯❯"),
|
||||
)
|
||||
self._console.start()
|
||||
# Comment to not hide the console for debugging
|
||||
self._console.hide()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize the UI"""
|
||||
# Add buttons
|
||||
widget = QWidget(self)
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
layout.addWidget(self.button_new_script)
|
||||
self.layout.addWidget(widget)
|
||||
self.layout.addWidget(self.tree_widget)
|
||||
# Uncomment to show the console for debugging
|
||||
# self.layout.addWidget(self._console)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
self._vscode_editor.hide()
|
||||
self._update_button_ui()
|
||||
self._setup_console()
|
||||
self.update_user_scripts()
|
||||
self._vscode_editor.file_saved.connect(self._handle_file_saved)
|
||||
|
||||
@Slot(str)
|
||||
def _handle_file_saved(self, fname: str):
|
||||
"""Handle the file saved signal"""
|
||||
self.update_user_scripts()
|
||||
|
||||
def _update_button_ui(self):
|
||||
"""Update the button UI"""
|
||||
colors = get_accent_colors()
|
||||
name = self.button_new_script.objectName()
|
||||
self.button_new_script.setStyleSheet(
|
||||
f"QWidget#{name} {{ color: {colors._palette.windowText().color().name()}; }}"
|
||||
)
|
||||
|
||||
def save_script(self):
|
||||
"""Save the script"""
|
||||
self._vscode_editor.save_file()
|
||||
self._vscode_editor.hide()
|
||||
if self._code_dialog is not None:
|
||||
self._code_dialog.hide()
|
||||
self.update_user_scripts()
|
||||
|
||||
def open_script(self, fname: str):
|
||||
"""Open a script
|
||||
|
||||
Args:
|
||||
fname (str): The file name of the script
|
||||
"""
|
||||
if self._code_dialog is None:
|
||||
self._code_dialog = VSCodeDialog(parent=self, editor=self._vscode_editor)
|
||||
self._code_dialog.show()
|
||||
self._vscode_editor.show()
|
||||
# Only works after show was called for the first time
|
||||
self._vscode_editor.zen_mode()
|
||||
else:
|
||||
self._code_dialog.show()
|
||||
self._vscode_editor.show()
|
||||
self._vscode_editor.open_file(fname)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new_script(self, *args, **kwargs):
|
||||
"""Create a new script"""
|
||||
self._new_script_dialog = InputDialog(
|
||||
header="New Script", info="Enter filename for new script", fields={"Filename": ""}
|
||||
)
|
||||
if self._new_script_dialog.exec_():
|
||||
name = self._new_script_dialog.get_inputs()["Filename"]
|
||||
check_name = name.replace("_", "").replace("-", "")
|
||||
if not check_name.isalnum() or not check_name.isascii():
|
||||
raise NameError(f"Invalid name {name}, must be alphanumeric and ascii")
|
||||
if not name.endswith(".py"):
|
||||
name = name + ".py"
|
||||
fname = os.path.join(self._base_path, name)
|
||||
# Check if file exists on disk
|
||||
if os.path.exists(fname):
|
||||
logger.error(f"File {fname} already exists")
|
||||
raise FileExistsError(f"File {fname} already exists")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(fname), exist_ok=True, mode=0o775)
|
||||
with open(fname, "w", encoding="utf-8") as f:
|
||||
f.write("# New BEC Script\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating new script: {e}")
|
||||
raise e
|
||||
self.open_script(fname)
|
||||
|
||||
def get_script_files(self) -> dict:
|
||||
"""Get all script files in the base path"""
|
||||
files = {"BEC": [], "USER": [], "BL": []}
|
||||
# bec
|
||||
bec_lib_path = pathlib.Path(bec_lib.__file__).parent.parent.resolve()
|
||||
bec_scripts_dir = os.path.join(str(bec_lib_path), "scripts")
|
||||
files["BEC"].extend(glob.glob(os.path.abspath(os.path.join(bec_scripts_dir, "*.py"))))
|
||||
|
||||
# user
|
||||
user_scripts_dir = os.path.join(os.path.expanduser("~"), "bec", "scripts")
|
||||
if os.path.exists(user_scripts_dir):
|
||||
files["USER"].extend(glob.glob(os.path.abspath(os.path.join(user_scripts_dir, "*.py"))))
|
||||
|
||||
# load scripts from the beamline plugin
|
||||
plugins = importlib.metadata.entry_points(group="bec")
|
||||
for plugin in plugins:
|
||||
if plugin.name == "plugin_bec":
|
||||
plugin = plugin.load()
|
||||
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
|
||||
if os.path.exists(plugin_scripts_dir):
|
||||
files["BL"].extend(
|
||||
glob.glob(os.path.abspath(os.path.join(plugin_scripts_dir, "*.py")))
|
||||
)
|
||||
return files
|
||||
|
||||
@SafeSlot()
|
||||
def reload_user_scripts(self, *args, **kwargs):
|
||||
"""Reload the user scripts"""
|
||||
self.client.load_all_user_scripts()
|
||||
|
||||
@Slot()
|
||||
def update_user_scripts(self) -> None:
|
||||
"""Update the user scripts"""
|
||||
self.user_scripts.clear()
|
||||
self.tree_widget.clear()
|
||||
script_files = self.get_script_files()
|
||||
for key, files in script_files.items():
|
||||
if len(files) == 0:
|
||||
continue
|
||||
top_item = self.tree_widget.add_top_item(key)
|
||||
for fname in files:
|
||||
mod_name = fname.split("/")[-1].strip(".py")
|
||||
self.user_scripts[mod_name] = ScriptBlock(
|
||||
fname=fname, module_name=mod_name, location=key
|
||||
)
|
||||
child_item = self.tree_widget.add_module_item(top_item, mod_name)
|
||||
# pylint: disable=protected-access
|
||||
self.reload_user_scripts(popup_error=True)
|
||||
for user_script_name, info in self.client._scripts.items():
|
||||
if info["fname"] == fname:
|
||||
self.user_scripts[mod_name].user_script_name = user_script_name
|
||||
_ = self.tree_widget.add_child_item(child_item, user_script_name)
|
||||
self.tree_widget.expandAll()
|
||||
|
||||
@Slot(str)
|
||||
def handle_edit_button_clicked(self, text: str):
|
||||
"""Handle the click of the edit button"""
|
||||
self.open_script(self.user_scripts[text].fname)
|
||||
|
||||
@Slot(str)
|
||||
def handle_play_button_clicked(self, text: str):
|
||||
"""Handle the click of the play button"""
|
||||
self._console.execute_command("bec.load_all_user_scripts()")
|
||||
info = self.client._scripts[text]
|
||||
caller_args = inspect.getfullargspec(info["cls"])
|
||||
args = caller_args.args + caller_args.kwonlyargs
|
||||
if args:
|
||||
self._handle_call_with_args(text, caller_args)
|
||||
else:
|
||||
self._console.execute_command(f"{text}()")
|
||||
|
||||
def _handle_call_with_args(self, text: str, caller_args: inspect.FullArgSpec) -> None:
|
||||
"""Handle the call with arguments"""
|
||||
defaults = []
|
||||
args = caller_args.args + caller_args.kwonlyargs
|
||||
for value in args:
|
||||
if caller_args.kwonlydefaults is not None:
|
||||
defaults.append(caller_args.kwonlydefaults.get(value, None))
|
||||
fields = dict((arg, default) for arg, default in zip(args, defaults))
|
||||
info = ", ".join([f"{k}={v}" for k, v in fields.items()]).replace("None", "")
|
||||
info = f"Example: {text}({info})"
|
||||
self._script_dialog = InputDialog(
|
||||
parent=self, header="Script Arguments", info=info, fields=fields
|
||||
)
|
||||
if self._script_dialog.exec_():
|
||||
args = self._script_dialog.get_inputs()
|
||||
args = ", ".join([f"{k}={v}" for k, v in args.items()])
|
||||
self._console.execute_command(f"{text}({args})")
|
||||
self._script_dialog = None
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget"""
|
||||
self._vscode_editor.cleanup()
|
||||
self._vscode_editor.deleteLater()
|
||||
if self._code_dialog is not None:
|
||||
self._code_dialog.deleteLater()
|
||||
if self._script_dialog is not None:
|
||||
self._script_dialog.deleteLater()
|
||||
if self._new_script_dialog is not None:
|
||||
self._new_script_dialog.deleteLater()
|
||||
self.tree_widget.clear()
|
||||
self._console.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.addWidget(UserScriptWidget())
|
||||
w.setFixedHeight(400)
|
||||
w.setFixedWidth(400)
|
||||
w.show()
|
||||
app.exec_()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['user_script.py']}
|
||||
@@ -45,12 +45,12 @@ class VSCodeEditor(WebsiteWidget):
|
||||
USER_ACCESS = []
|
||||
ICON_NAME = "developer_mode_tv"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None):
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
|
||||
self.process = None
|
||||
self.port = get_free_port()
|
||||
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id)
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
|
||||
self.start_server()
|
||||
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ class WebsiteWidget(BECWidget, QWidget):
|
||||
ICON_NAME = "travel_explore"
|
||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
||||
|
||||
def __init__(self, parent=None, url: str = None, config=None, client=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
def __init__(
|
||||
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
3
bec_widgets/widgets/games/__init__.py
Normal file
3
bec_widgets/widgets/games/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from bec_widgets.widgets.games.minesweeper import Minesweeper
|
||||
|
||||
__ALL__ = ["Minesweeper"]
|
||||
416
bec_widgets/widgets/games/minesweeper.py
Normal file
416
bec_widgets/widgets/games/minesweeper.py
Normal file
@@ -0,0 +1,416 @@
|
||||
import enum
|
||||
import random
|
||||
import time
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
|
||||
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
NUM_COLORS = {
|
||||
1: QColor("#f44336"),
|
||||
2: QColor("#9C27B0"),
|
||||
3: QColor("#3F51B5"),
|
||||
4: QColor("#03A9F4"),
|
||||
5: QColor("#00BCD4"),
|
||||
6: QColor("#4CAF50"),
|
||||
7: QColor("#E91E63"),
|
||||
8: QColor("#FF9800"),
|
||||
}
|
||||
|
||||
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
|
||||
|
||||
|
||||
class GameStatus(enum.Enum):
|
||||
READY = 0
|
||||
PLAYING = 1
|
||||
FAILED = 2
|
||||
SUCCESS = 3
|
||||
|
||||
|
||||
class Pos(QWidget):
|
||||
expandable = Signal(int, int)
|
||||
clicked = Signal()
|
||||
ohno = Signal()
|
||||
|
||||
def __init__(self, x, y, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setFixedSize(QSize(20, 20))
|
||||
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.is_start = False
|
||||
self.is_mine = False
|
||||
self.adjacent_n = 0
|
||||
self.is_revealed = False
|
||||
self.is_flagged = False
|
||||
|
||||
def reset(self):
|
||||
"""Restore the tile to its original state before mine status is assigned"""
|
||||
self.is_start = False
|
||||
self.is_mine = False
|
||||
self.adjacent_n = 0
|
||||
|
||||
self.is_revealed = False
|
||||
self.is_flagged = False
|
||||
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
|
||||
r = event.rect()
|
||||
|
||||
if self.is_revealed:
|
||||
color = self.palette().base().color()
|
||||
outer, inner = color, color
|
||||
else:
|
||||
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
|
||||
|
||||
p.fillRect(r, QBrush(inner))
|
||||
pen = QPen(outer)
|
||||
pen.setWidth(1)
|
||||
p.setPen(pen)
|
||||
p.drawRect(r)
|
||||
|
||||
if self.is_revealed:
|
||||
if self.is_mine:
|
||||
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
|
||||
|
||||
elif self.adjacent_n > 0:
|
||||
pen = QPen(NUM_COLORS[self.adjacent_n])
|
||||
p.setPen(pen)
|
||||
f = p.font()
|
||||
f.setBold(True)
|
||||
p.setFont(f)
|
||||
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
|
||||
|
||||
elif self.is_flagged:
|
||||
p.drawPixmap(
|
||||
r,
|
||||
material_icon(
|
||||
"flag",
|
||||
size=(50, 50),
|
||||
convert_to_pixmap=True,
|
||||
filled=True,
|
||||
color=self.palette().base().color(),
|
||||
),
|
||||
)
|
||||
p.end()
|
||||
|
||||
def flag(self):
|
||||
self.is_flagged = not self.is_flagged
|
||||
self.update()
|
||||
|
||||
self.clicked.emit()
|
||||
|
||||
def reveal(self):
|
||||
self.is_revealed = True
|
||||
self.update()
|
||||
|
||||
def click(self):
|
||||
if not self.is_revealed:
|
||||
self.reveal()
|
||||
if self.adjacent_n == 0:
|
||||
self.expandable.emit(self.x, self.y)
|
||||
|
||||
self.clicked.emit()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
|
||||
self.flag()
|
||||
return
|
||||
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.click()
|
||||
if self.is_mine:
|
||||
self.ohno.emit()
|
||||
|
||||
|
||||
class Minesweeper(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "videogame_asset"
|
||||
USER_ACCESS = []
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self._ui_initialised = False
|
||||
self._timer_start_num_seconds = 0
|
||||
self._set_level_params(LEVELS["1"])
|
||||
|
||||
self._init_ui()
|
||||
self._init_map()
|
||||
|
||||
self.update_status(GameStatus.READY)
|
||||
self.reset_map()
|
||||
self.update_status(GameStatus.READY)
|
||||
|
||||
def _init_ui(self):
|
||||
if self._ui_initialised:
|
||||
return
|
||||
self._ui_initialised = True
|
||||
|
||||
status_hb = QHBoxLayout()
|
||||
self.mines = QLabel()
|
||||
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = self.mines.font()
|
||||
f.setPointSize(24)
|
||||
self.mines.setFont(f)
|
||||
|
||||
self.reset_button = QPushButton()
|
||||
self.reset_button.setFixedSize(QSize(32, 32))
|
||||
self.reset_button.setIconSize(QSize(32, 32))
|
||||
self.reset_button.setFlat(True)
|
||||
self.reset_button.pressed.connect(self.reset_button_pressed)
|
||||
|
||||
self.clock = QLabel()
|
||||
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
self.clock.setFont(f)
|
||||
self._timer = QTimer()
|
||||
self._timer.timeout.connect(self.update_timer)
|
||||
self._timer.start(1000) # 1 second timer
|
||||
self.mines.setText(f"{self.num_mines:03d}")
|
||||
self.clock.setText("000")
|
||||
|
||||
status_hb.addWidget(self.mines)
|
||||
status_hb.addWidget(self.reset_button)
|
||||
status_hb.addWidget(self.clock)
|
||||
|
||||
level_hb = QHBoxLayout()
|
||||
self.level_selector = QComboBox()
|
||||
self.level_selector.addItems(list(LEVELS.keys()))
|
||||
level_hb.addWidget(QLabel("Level: "))
|
||||
level_hb.addWidget(self.level_selector)
|
||||
self.level_selector.currentTextChanged.connect(self.change_level)
|
||||
|
||||
vb = QVBoxLayout()
|
||||
vb.addLayout(level_hb)
|
||||
vb.addLayout(status_hb)
|
||||
|
||||
self.grid = QGridLayout()
|
||||
self.grid.setSpacing(5)
|
||||
|
||||
vb.addLayout(self.grid)
|
||||
self.setLayout(vb)
|
||||
|
||||
def _init_map(self):
|
||||
"""Redraw the grid of mines"""
|
||||
|
||||
# Remove any previous grid items and reset the grid
|
||||
for i in reversed(range(self.grid.count())):
|
||||
w: Pos = self.grid.itemAt(i).widget()
|
||||
w.clicked.disconnect(self.on_click)
|
||||
w.expandable.disconnect(self.expand_reveal)
|
||||
w.ohno.disconnect(self.game_over)
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
|
||||
# Add positions to the map
|
||||
for x in range(0, self.b_size):
|
||||
for y in range(0, self.b_size):
|
||||
w = Pos(x, y)
|
||||
self.grid.addWidget(w, y, x)
|
||||
# Connect signal to handle expansion.
|
||||
w.clicked.connect(self.on_click)
|
||||
w.expandable.connect(self.expand_reveal)
|
||||
w.ohno.connect(self.game_over)
|
||||
|
||||
def reset_map(self):
|
||||
"""
|
||||
Reset the map and add new mines.
|
||||
"""
|
||||
# Clear all mine positions
|
||||
for x in range(0, self.b_size):
|
||||
for y in range(0, self.b_size):
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
w.reset()
|
||||
|
||||
# Add mines to the positions
|
||||
positions = []
|
||||
while len(positions) < self.num_mines:
|
||||
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
|
||||
if (x, y) not in positions:
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
w.is_mine = True
|
||||
positions.append((x, y))
|
||||
|
||||
def get_adjacency_n(x, y):
|
||||
positions = self.get_surrounding(x, y)
|
||||
num_mines = sum(1 if w.is_mine else 0 for w in positions)
|
||||
|
||||
return num_mines
|
||||
|
||||
# Add adjacencies to the positions
|
||||
for x in range(0, self.b_size):
|
||||
for y in range(0, self.b_size):
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
w.adjacent_n = get_adjacency_n(x, y)
|
||||
|
||||
# Place starting marker
|
||||
while True:
|
||||
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
# We don't want to start on a mine.
|
||||
if (x, y) not in positions:
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
w.is_start = True
|
||||
|
||||
# Reveal all positions around this, if they are not mines either.
|
||||
for w in self.get_surrounding(x, y):
|
||||
if not w.is_mine:
|
||||
w.click()
|
||||
break
|
||||
|
||||
def get_surrounding(self, x, y):
|
||||
positions = []
|
||||
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
|
||||
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
|
||||
positions.append(self.grid.itemAtPosition(yi, xi).widget())
|
||||
return positions
|
||||
|
||||
def get_num_hidden(self) -> int:
|
||||
"""
|
||||
Get the number of hidden positions.
|
||||
"""
|
||||
return sum(
|
||||
1
|
||||
for x in range(0, self.b_size)
|
||||
for y in range(0, self.b_size)
|
||||
if not self.grid.itemAtPosition(y, x).widget().is_revealed
|
||||
)
|
||||
|
||||
def get_num_remaining_flags(self) -> int:
|
||||
"""
|
||||
Get the number of remaining flags.
|
||||
"""
|
||||
return self.num_mines - sum(
|
||||
1
|
||||
for x in range(0, self.b_size)
|
||||
for y in range(0, self.b_size)
|
||||
if self.grid.itemAtPosition(y, x).widget().is_flagged
|
||||
)
|
||||
|
||||
def reset_button_pressed(self):
|
||||
match self.status:
|
||||
case GameStatus.PLAYING:
|
||||
self.game_over()
|
||||
case GameStatus.FAILED | GameStatus.SUCCESS:
|
||||
self.reset_map()
|
||||
|
||||
def reveal_map(self):
|
||||
for x in range(0, self.b_size):
|
||||
for y in range(0, self.b_size):
|
||||
w = self.grid.itemAtPosition(y, x).widget()
|
||||
w.reveal()
|
||||
|
||||
@Slot(str)
|
||||
def change_level(self, level: str):
|
||||
self._set_level_params(LEVELS[level])
|
||||
self._init_map()
|
||||
self.reset_map()
|
||||
|
||||
@Slot(int, int)
|
||||
def expand_reveal(self, x, y):
|
||||
"""
|
||||
Expand the reveal to the surrounding
|
||||
|
||||
Args:
|
||||
x (int): The x position.
|
||||
y (int): The y position.
|
||||
"""
|
||||
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
|
||||
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
|
||||
w = self.grid.itemAtPosition(yi, xi).widget()
|
||||
if not w.is_mine:
|
||||
w.click()
|
||||
|
||||
@Slot()
|
||||
def on_click(self):
|
||||
"""
|
||||
Handle the click event. If the game is not started, start the game.
|
||||
"""
|
||||
self.update_available_flags()
|
||||
if self.status != GameStatus.PLAYING:
|
||||
# First click.
|
||||
self.update_status(GameStatus.PLAYING)
|
||||
# Start timer.
|
||||
self._timer_start_num_seconds = int(time.time())
|
||||
return
|
||||
self.check_win()
|
||||
|
||||
def update_available_flags(self):
|
||||
"""
|
||||
Update the number of available flags.
|
||||
"""
|
||||
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
|
||||
|
||||
def check_win(self):
|
||||
"""
|
||||
Check if the game is won.
|
||||
"""
|
||||
if self.get_num_hidden() == self.num_mines:
|
||||
self.update_status(GameStatus.SUCCESS)
|
||||
|
||||
def update_status(self, status: GameStatus):
|
||||
"""
|
||||
Update the status of the game.
|
||||
|
||||
Args:
|
||||
status (GameStatus): The status of the game.
|
||||
"""
|
||||
self.status = status
|
||||
match status:
|
||||
case GameStatus.READY:
|
||||
icon = material_icon(icon_name="add", convert_to_pixmap=False)
|
||||
case GameStatus.PLAYING:
|
||||
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
|
||||
case GameStatus.FAILED:
|
||||
icon = material_icon(icon_name="error", convert_to_pixmap=False)
|
||||
case GameStatus.SUCCESS:
|
||||
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
|
||||
self.reset_button.setIcon(icon)
|
||||
|
||||
def update_timer(self):
|
||||
"""
|
||||
Update the timer.
|
||||
"""
|
||||
if self.status == GameStatus.PLAYING:
|
||||
num_seconds = int(time.time()) - self._timer_start_num_seconds
|
||||
self.clock.setText(f"{num_seconds:03d}")
|
||||
|
||||
def game_over(self):
|
||||
"""Cause the game to end early"""
|
||||
self.reveal_map()
|
||||
self.update_status(GameStatus.FAILED)
|
||||
|
||||
def _set_level_params(self, level: tuple[int, int]):
|
||||
self.b_size, self.num_mines = level
|
||||
|
||||
def cleanup(self):
|
||||
self._timer.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("light")
|
||||
widget = Minesweeper()
|
||||
widget.show()
|
||||
|
||||
app.exec_()
|
||||
1
bec_widgets/widgets/games/minesweeper.pyproject
Normal file
1
bec_widgets/widgets/games/minesweeper.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['minesweeper.py']}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user