1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

6 Commits

140 changed files with 1759 additions and 17440 deletions

View File

@@ -61,7 +61,6 @@ 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
@@ -78,9 +77,9 @@ formatter:
stage: Formatter
needs: []
script:
- 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 ./
- pip install black isort
- isort --check --diff ./
- black --check --diff --color ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
@@ -148,7 +147,7 @@ tests:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
@@ -172,6 +171,7 @@ 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,pyside6]
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,12 @@
# 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[pyside6]
pip install bec_widgets PyQt6
```
For development purposes, you can clone the repository and install the package locally in editable mode:
@@ -19,12 +14,22 @@ 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,pyside6]
pip install -e .[dev,pyqt6]
```
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
specified.
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.
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/).
@@ -34,7 +39,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>
> │ │ │
@@ -48,13 +53,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
@@ -66,5 +71,4 @@ 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/)

View File

@@ -27,17 +27,25 @@ class AutoUpdates:
def __init__(self, gui: BECDockArea):
self.gui = gui
self._default_dock = None
self._default_fig = None
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()
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.add_dock(self.dock_name)
self._default_dock.add_widget("BECFigure")
self._default_fig = self._default_dock.widget_list[0]
@staticmethod
def get_scan_info(msg) -> ScanInfo:
@@ -65,9 +73,15 @@ class AutoUpdates:
"""
Get the default figure from the GUI.
"""
return self._default_fig
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]
def do_update(self, msg):
def run(self, msg):
"""
Run the update function if enabled.
"""
@@ -76,9 +90,20 @@ class AutoUpdates:
if msg.status != "open":
return
info = self.get_scan_info(msg)
return self.handler(info)
self.handler(info)
def get_selected_device(self, monitored_devices, selected_device):
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):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
@@ -95,11 +120,14 @@ class AutoUpdates:
Default update function.
"""
if info.scan_name == "line_scan" and info.scan_report_devices:
return self.simple_line_scan(info)
self.simple_line_scan(info)
return
if info.scan_name == "grid_scan" and info.scan_report_devices:
return self.simple_grid_scan(info)
self.simple_grid_scan(info)
return
if info.scan_report_devices:
return self.best_effort(info)
self.best_effort(info)
return
def simple_line_scan(self, info: ScanInfo) -> None:
"""
@@ -109,19 +137,12 @@ class AutoUpdates:
if not fig:
return
dev_x = info.scan_report_devices[0]
selected_device = yield self.gui.selected_device
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
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,
)
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 simple_grid_scan(self, info: ScanInfo) -> None:
"""
@@ -132,18 +153,12 @@ class AutoUpdates:
return
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
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,
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}"
)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
"""
@@ -153,16 +168,17 @@ class AutoUpdates:
if not fig:
return
dev_x = info.scan_report_devices[0]
selected_device = yield self.gui.selected_device
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
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,
)
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()

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import enum
from typing import Literal, Optional, overload
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.client_utils import BECGuiClientMixin, RPCBase, rpc_call
# pylint: skip-file
@@ -31,11 +31,8 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
@@ -45,6 +42,7 @@ class Widgets(str, enum.Enum):
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
UserScriptWidget = "UserScriptWidget"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
@@ -345,7 +343,7 @@ class BECDock(RPCBase):
"""
class BECDockArea(RPCBase):
class BECDockArea(RPCBase, BECGuiClientMixin):
@property
@rpc_call
def _config_dict(self) -> "dict":
@@ -356,13 +354,6 @@ class BECDockArea(RPCBase):
dict: The configuration of the widget.
"""
@property
@rpc_call
def selected_device(self) -> "str":
"""
None
"""
@property
@rpc_call
def panels(self) -> "dict[str, BECDock]":
@@ -490,12 +481,6 @@ class BECDockArea(RPCBase):
Hide all windows including floating docks.
"""
@rpc_call
def delete(self):
"""
None
"""
class BECFigure(RPCBase):
@property
@@ -3184,29 +3169,6 @@ class LMFitDialog(RPCBase):
"""
class LogPanel(RPCBase):
@rpc_call
def set_plain_text(self, text: str) -> None:
"""
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_html_text(self, text: str) -> None:
"""
Set the HTML text of the widget.
Args:
text (str): The text to set.
"""
class Minesweeper(RPCBase): ...
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
@@ -3257,51 +3219,6 @@ class PositionerBox(RPCBase):
"""
class PositionerBox2D(RPCBase):
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def set_positioner_ver(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerBoxBase(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class PositionerControlLine(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
@@ -3772,6 +3689,9 @@ class TextBox(RPCBase):
"""
class UserScriptWidget(RPCBase): ...
class VSCodeEditor(RPCBase): ...

View File

@@ -7,42 +7,59 @@ import os
import select
import subprocess
import threading
from contextlib import contextmanager
from dataclasses import dataclass
import time
import uuid
from functools import wraps
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
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
from bec_widgets.cli.rpc.rpc_base import RPCBase
if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_widgets.utils.bec_dispatcher import BECDispatcher
else:
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",))
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",))
logger = bec_logger.logger
def _filter_output(output: str) -> str:
def rpc_call(func):
"""
Filter out the output from the process.
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
"""
if "IMKClient" in output:
# only relevant on macOS
# see https://discussions.apple.com/thread/255761734?sortBy=rank
return ""
return output
@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
def _get_output(process, logger) -> None:
@@ -58,7 +75,6 @@ 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()
@@ -116,79 +132,29 @@ class RepeatTimer(threading.Timer):
self.function(*self.args, **self.kwargs)
@contextmanager
def wait_for_server(client):
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
### ----------------------------
### NOTE
### it is far easier to extend the 'delete' method on the client side,
### to know when the client is deleted, rather than listening to server
### to get notified. However, 'generate_cli.py' cannot add extra stuff
### in the generated client module. So, here a class with the same name
### is created, and client module is patched.
class BECDockArea(client.BECDockArea):
def delete(self):
if self is BECGuiClient._top_level["main"].widget:
raise RuntimeError("Cannot delete main window")
super().delete()
try:
del BECGuiClient._top_level[self._gui_id]
except KeyError:
# if a dock area is not at top level
pass
client.BECDockArea = BECDockArea
### ----------------------------
@dataclass
class WidgetDesc:
title: str
widget: BECDockArea
class BECGuiClient(RPCBase):
_top_level = {}
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._auto_updates_enabled = True
self._auto_updates = None
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
@property
def windows(self):
return self._top_level
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
@property
def auto_updates(self):
if self._auto_updates_enabled:
with wait_for_server(self):
return self._auto_updates
self._gui_started_event.wait()
return self._auto_updates
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
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
@@ -209,59 +175,49 @@ class BECGuiClient(RPCBase):
"""
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
return self._selected_device
@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),
)
self._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),
)
self._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(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
self._client.connector.register(
self._target_endpoint, cb=self._handle_msg_update, parent=self
)
def _handle_msg_update(self, msg: MessageObject) -> None:
if self.auto_updates is not None:
@staticmethod
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.auto_updates is not None:
# pylint: disable=protected-access
return self._update_script_msg_parser(msg.value)
parent._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:
return self.auto_updates.do_update(msg)
self.auto_updates.msg_queue.put(msg)
def _gui_post_startup(self):
self._top_level["main"] = WidgetDesc(
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
)
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["main"].widget)
auto_updates = AutoUpdates(gui=self)
if auto_updates.create_default_dock:
auto_updates.start_default_dock()
self._start_update_script()
# fig = auto_updates.get_default_figure()
self._auto_updates = auto_updates
self._do_show_all()
self._gui_started_event.set()
self.show_all()
def start_server(self, wait=False) -> None:
"""
@@ -269,8 +225,8 @@ class BECGuiClient(RPCBase):
"""
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
)
@@ -283,66 +239,27 @@ class BECGuiClient(RPCBase):
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
1, 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 _dump(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
return rpc_client._run_rpc("_dump")
def start(self):
return self.start_server()
def _do_show_all(self):
def show_all(self):
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
for window in self._top_level.values():
window.widget.show()
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")
for window in self._top_level.values():
window.widget.hide()
def show(self):
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):
return self.hide_all()
@property
def main(self):
"""Return client to main dock area (in main window)"""
with wait_for_server(self):
return self._top_level["main"].widget
def new(self, title):
"""Ask main window to create a new top-level dock area"""
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", title)
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
return widget
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide")
def close(self) -> None:
"""
Close the gui window.
"""
self._top_level.clear()
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
@@ -357,3 +274,130 @@ class BECGuiClient(RPCBase):
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}"
)
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

View File

@@ -35,7 +35,7 @@ from __future__ import annotations
import enum
from typing import Literal, Optional, overload
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
# pylint: skip-file"""
@@ -43,21 +43,14 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
def generate_client(self, class_container: BECClassContainer):
"""
Generate the client for the published classes, skipping any classes
that have `RPC = False`.
Generate the client for the published classes.
Args:
class_container: The class container with the classes to generate the client for.
"""
# 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 = class_container.rpc_top_level_classes
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = [
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
]
connector_classes = class_container.connector_classes
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
@@ -88,13 +81,13 @@ class Widgets(str, enum.Enum):
class_name = cls.__name__
if class_name == "BECDockArea":
# Generate the content
if cls.__name__ == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase):"""
class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
@@ -107,10 +100,8 @@ class {class_name}(RPCBase):"""
method = method.split(".setter")[0]
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
f"Method {method} not found in class {cls.__name__}. 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:

View File

@@ -1,177 +0,0 @@
from __future__ import annotations
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING
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.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 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(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)
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

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
import functools
import json
import signal
import sys
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from contextlib import redirect_stderr, redirect_stdout
from typing import Union
from bec_lib.endpoints import MessageEndpoints
@@ -14,8 +12,7 @@ from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.containers.dock import BECDockArea
@@ -26,27 +23,6 @@ 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
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__(
@@ -81,19 +57,18 @@ class BECWidgetsCLIServer:
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
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})
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(
@@ -206,8 +181,14 @@ 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, default="test", help="The id of the server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
"--gui_class",
type=str,
@@ -218,16 +199,10 @@ def main():
args = parser.parse_args()
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":
if args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
@@ -238,10 +213,8 @@ def main():
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
# 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)
app.setQuitOnLastWindowClosed(False)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
@@ -249,8 +222,6 @@ 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)
@@ -262,6 +233,7 @@ def main():
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
if not args.hide:
win.show()
@@ -270,12 +242,6 @@ 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)
@@ -284,5 +250,6 @@ def main():
sys.exit(app.exec())
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
main()

View File

@@ -7,7 +7,6 @@ from qtpy.QtWidgets import (
QApplication,
QGroupBox,
QHBoxLayout,
QPushButton,
QSplitter,
QTabWidget,
QVBoxLayout,
@@ -18,9 +17,7 @@ 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
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -53,18 +50,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"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,
}
)
@@ -89,34 +79,11 @@ 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, "PltoBase")
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")
# add stuff to figure
self._init_figure()
@@ -126,7 +93,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.setWindowTitle("Jupyter Console Window")
def _init_figure(self):
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
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.set(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
@@ -194,6 +169,14 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.wf = self.d2.add_widget("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.dock.save_state()

View File

@@ -1,380 +0,0 @@
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())

View File

@@ -2,93 +2,9 @@ import functools
import sys
import traceback
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtCore import 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
@@ -106,13 +22,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
try:
return method(*args, **kwargs)
except Exception:
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}")
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
return wrapper
@@ -181,12 +91,6 @@ 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.
@@ -223,14 +127,12 @@ 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",
self.get_error_message(exctype, value, tb),
"".join(error_message),
self.parent(),
)
else:

View File

@@ -1,180 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, 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")
self.update_style()
# Create a layout for the frame
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the PlotWidget if applicable
if isinstance(content_widget, pg.PlotWidget):
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()
# Update PlotWidget's background color and axis styles if applicable
if isinstance(self.content_widget, pg.PlotWidget):
self.apply_plot_widget_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 */
}}
"""
)
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.PlotWidget):
# Sync PlotWidget's background color with the RoundedFrame's background color
self.content_widget.setBackground(self.background_color)
# Calculate contrast-optimized axis and label colors
if self.background_color == "#e9ecef": # Light mode
label_color = "#000000"
axis_color = "#666666"
else: # Dark mode
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
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)
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
PlotWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
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.PlotWidget()
plot1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot2 = pg.PlotWidget()
plot2.plot([1, 2, 4, 8, 16, 32], pen="r")
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
round = RoundedFrame()
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(round)
self.setLayout(layout)
# Simulate theme change after 2 seconds
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()

View File

@@ -1,369 +0,0 @@
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.waveform.waveform_widget import BECWaveformWidget
self.plot = BECWaveformWidget()
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())

View File

@@ -2,20 +2,17 @@
from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from typing import Literal
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QSizePolicy,
QToolBar,
@@ -34,7 +31,7 @@ class ToolBarAction(ABC):
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
@@ -84,18 +81,15 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class MaterialIconAction(ToolBarAction):
class MaterialIconAction:
"""
Action with a Material icon for the toolbar.
Args:
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, 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__(
@@ -105,42 +99,30 @@ class MaterialIconAction(ToolBarAction):
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
# Generate the icon
self.icon = material_icon(
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(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
# Immediately create an QAction with the given parent
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
return icon
class DeviceSelectionAction(ToolBarAction):
@@ -150,6 +132,7 @@ 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):
@@ -177,6 +160,7 @@ 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):
@@ -235,6 +219,7 @@ 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):
@@ -274,399 +259,38 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button)
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, 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 you passed in a list of tuples, load them into the dictionary
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.
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.
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)" - transparent background.
"""
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)",
):
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
super().__init__(parent)
self.widgets = defaultdict(dict)
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 = []
self.set_background_color()
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: QWidget):
def populate_toolbar(self, actions: dict, target_widget):
"""Populates the toolbar with a set of actions.
Args:
actions (dict): A dictionary of action creators to populate the toolbar.
actions (list[ToolBarAction]): A list 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, color: str = "rgba(0, 0, 0, 0)"):
"""
Sets the background color and other appearance settings.
Args:
color(str): The background color of the toolbar.
"""
def set_background_color(self):
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
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 in the toolbar.
Args:
new_color (str | tuple | QColor): The new color for the icons.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
# Refresh the icon
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 to the toolbar dynamically.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add to the toolbar.
target_widget (QWidget): The target widget for the action.
"""
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() # Update separators after adding the action
def hide_action(self, action_id: str):
"""
Hides a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to hide.
"""
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 isinstance(action.action, QAction):
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
def show_action(self, action_id: str):
"""
Shows a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to show.
"""
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 isinstance(action.action, QAction):
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators() # Update separators after adding the bundle
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Args:
event(QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
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)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
"""
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 toggling of toolbar actions from the context menu."""
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 on the toolbar.
Args:
action_id(str): Unique identifier for the action to toggle.
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 isinstance(tool_action.action, QAction):
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
"""
toolbar_actions = self.actions()
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
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)
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)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
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)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, 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)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")

View File

@@ -224,11 +224,3 @@ 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

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import os
import time
import uuid
from typing import TYPE_CHECKING, Optional
from typing import Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
@@ -12,14 +12,11 @@ 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.rpc_register import RPCRegister
from bec_widgets.cli.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.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",))

View File

@@ -93,24 +93,17 @@ 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

View File

@@ -5,43 +5,28 @@ 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 QTimer, Signal
from bec_widgets.qt_utils.error_popups import SafeSlot
from qtpy.QtCore import Signal, Slot
class BECSignalProxy(SignalProxy):
"""
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
but arguments still being stored.
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
Args:
*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.
*args: Arguments to pass to the SignalProxy class
rateLimit (int): The rateLimit of the proxy
**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, timeout=10.0, **kwargs):
def __init__(self, *args, rateLimit=25, **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"""
@@ -61,22 +46,9 @@ class BECSignalProxy(SignalProxy):
self.old_args = args
super().signalReceived(*args)
self._timer.start(int(self._timeout * 1000))
@SafeSlot()
@Slot()
def unblock_proxy(self):
"""Unblock the proxy, and call the signalReceived method in case there was an update of the 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()
self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)

View File

@@ -1,5 +1,6 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import (
QApplication,
@@ -15,8 +16,6 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@@ -29,15 +28,6 @@ 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."""
@@ -48,9 +38,6 @@ 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."""
@@ -66,11 +53,6 @@ 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."""
@@ -90,16 +72,6 @@ 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."""
@@ -110,9 +82,6 @@ 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."""
@@ -123,22 +92,6 @@ 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."""
@@ -146,15 +99,12 @@ class LabelHandler(WidgetHandler):
def get_value(self, widget, **kwargs):
return widget.text()
def set_value(self, widget: QLabel, value):
def set_value(self, widget, 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, setting values and connecting signals using handler mapping"""
"""Public interface for getting and setting values using handler mapping"""
_handlers = {
QLineEdit: LineEditHandler,
@@ -164,7 +114,6 @@ class WidgetIO:
QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
}
@staticmethod
@@ -199,17 +148,6 @@ 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):
"""
@@ -371,8 +309,8 @@ class WidgetHierarchy:
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
# Example usage
def hierarchy_example(): # pragma: no cover
# Example application to demonstrate the usage of the functions
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
# Create instance of WidgetHierarchy
@@ -427,37 +365,3 @@ def hierarchy_example(): # 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()

View File

@@ -1,223 +0,0 @@
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_())

View File

@@ -0,0 +1 @@

View File

@@ -6,7 +6,7 @@ from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget

View File

@@ -3,12 +3,11 @@ from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import QSize, Qt
from qtpy.QtCore import Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
@@ -20,7 +19,7 @@ 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 import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.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
@@ -30,7 +29,6 @@ from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
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
@@ -45,7 +43,6 @@ class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_config_dict",
"selected_device",
"panels",
"save_state",
"remove_dock",
@@ -58,7 +55,6 @@ class BECDockArea(BECWidget, QWidget):
"temp_areas",
"show",
"hide",
"delete",
]
def __init__(
@@ -140,9 +136,6 @@ 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(),
@@ -165,9 +158,6 @@ 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(
@@ -204,9 +194,6 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
@@ -223,17 +210,6 @@ class BECDockArea(BECWidget, QWidget):
"Add docks using 'add_dock' 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]:
"""
@@ -430,17 +406,6 @@ class BECDockArea(BECWidget, QWidget):
self.dock_area.deleteLater()
super().cleanup()
def closeEvent(self, event):
if self.parent() is None:
# we are at top-level (independent window)
if self.isVisible():
# we are visible => user clicked on [X]
# (when closeEvent is called from shutdown procedure,
# everything is hidden first)
# so, let's ignore "close", and do hide instead
event.ignore()
self.setVisible(False)
def close(self):
"""
Close the dock area and cleanup.
@@ -453,24 +418,14 @@ class BECDockArea(BECWidget, QWidget):
"""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(self):
self.hide()
self.deleteLater()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication

View File

@@ -1,882 +0,0 @@
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_())

View File

@@ -1,41 +1,9 @@
from qtpy.QtWidgets import QApplication, QMainWindow
from qtpy.QtWidgets import QMainWindow
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **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):
dock_area = BECDockArea()
dock_area.resize(dock_area.minimumSizeHint())
dock_area.window().setWindowTitle(name)
dock_area.show()
return dock_area

View File

@@ -1,11 +0,0 @@
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"]

View File

@@ -1,3 +0,0 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]

View File

@@ -1,243 +0,0 @@
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

View File

@@ -0,0 +1,352 @@
""" 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_())

View File

@@ -1,242 +0,0 @@
""" 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_())

View File

@@ -1,56 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
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_2d.positioner_box_2d import (
PositionerBox2D,
)
DOM_XML = """
<ui language='c++'>
<widget class='PositionerBox2D' name='positioner_box2_d'>
</widget>
</ui>
"""
class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = PositionerBox2D(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox2D.ICON_NAME)
def includeFile(self):
return "positioner_box2_d"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "PositionerBox2D"
def toolTip(self):
return "Simple Widget to control two positioners in box form"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,482 +0,0 @@
""" 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_())

View File

@@ -1,562 +0,0 @@
<?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>

View File

@@ -1,17 +0,0 @@
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()

View File

@@ -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 import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>

View File

@@ -1,8 +1,6 @@
import os
from bec_lib.device import Positioner
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
class PositionerControlLine(PositionerBox):
@@ -14,14 +12,13 @@ class PositionerControlLine(PositionerBox):
PLUGIN = True
ICON_NAME = "switch_left"
def __init__(self, parent=None, device: Positioner | str | None = None, *args, **kwargs):
def __init__(self, parent=None, device: Positioner = 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)

View File

@@ -6,7 +6,9 @@ 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 import PositionerControlLine
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line import (
PositionerControlLine,
)
DOM_XML = """
<ui language='c++'>

View File

@@ -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.positioner_box_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
PositionerBoxPlugin,
)

View File

@@ -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.positioner_control_line_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line_plugin import (
PositionerControlLinePlugin,
)

View File

@@ -4,12 +4,11 @@ from __future__ import annotations
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import Property, QSize, Signal, Slot
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 import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
logger = bec_logger.logger
@@ -33,7 +32,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.force_update_readback()
self.widget.init_device() # force readback
def _on_expand(self, expand):
if expand:
@@ -83,7 +82,7 @@ class PositionerGroup(BECWidget, QWidget):
def minimumSizeHint(self):
return QSize(300, 30)
@SafeSlot(str)
@Slot(str)
def set_positioners(self, device_names: str):
"""Redraw grid with positioners from device_names string
@@ -131,7 +130,7 @@ class PositionerGroup(BECWidget, QWidget):
widget = self.sender()
self.device_position_update.emit(widget.title(), pos)
@SafeProperty(str)
@Property(str)
def devices_list(self):
"""Device names string separated by space"""
return " ".join(self._device_widgets)
@@ -145,7 +144,7 @@ class PositionerGroup(BECWidget, QWidget):
return
self.set_positioners(device_names)
@SafeProperty(int)
@Property(int)
def grid_max_cols(self):
"""Max number of columns for widgets grid"""
return self._grid_ncols

View File

@@ -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_combo_box_plugin import (
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox_plugin import (
SignalComboBoxPlugin,
)

View File

@@ -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_combo_box'>
<widget class='SignalComboBox' name='signal_combobox'>
</widget>
</ui>
"""
@@ -33,7 +33,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return designer_material_icon(SignalComboBox.ICON_NAME)
def includeFile(self):
return "signal_combo_box"
return "signal_combobox"
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 "Signal ComboBox Example for BEC Widgets with autocomplete."
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -50,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalLineEdit"
def toolTip(self):
return "Signal LineEdit Example for BEC Widgets with autocomplete."
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -1,10 +1,9 @@
import os
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import Property, Signal, Slot
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
@@ -44,8 +43,6 @@ class LMFitDialog(BECWidget, QWidget):
"""
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")
self._ui_file = ui_file
self.target_widget = target_widget
@@ -68,7 +65,7 @@ class LMFitDialog(BECWidget, QWidget):
@property
def enable_actions(self) -> bool:
"""SafeProperty to enable the move to buttons."""
"""Property to enable the move to buttons."""
return self._enable_actions
@enable_actions.setter
@@ -77,37 +74,37 @@ class LMFitDialog(BECWidget, QWidget):
for button in self.action_buttons.values():
button.setEnabled(enable)
@SafeProperty(list)
@Property(list)
def active_action_list(self) -> list[str]:
"""SafeProperty to list the names of the fit parameters for which actions should be enabled."""
"""Property 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 SafeSlot needed?
@SafeSlot(bool)
# This slot needed?
@Slot(bool)
def set_actions_enabled(self, enable: bool) -> bool:
"""SafeSlot to enable the move to buttons.
"""Slot to enable the move to buttons.
Args:
enable (bool): Whether to enable the action buttons.
"""
self.enable_actions = enable
@SafeProperty(bool)
@Property(bool)
def always_show_latest(self):
"""SafeProperty to indicate if always the latest DAP update is displayed."""
"""Property 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
@SafeProperty(bool)
@Property(bool)
def hide_curve_selection(self):
"""SafeProperty for showing the curve selection."""
"""Property for showing the curve selection."""
return not self.ui.group_curve_selection.isVisible()
@hide_curve_selection.setter
@@ -119,9 +116,9 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.ui.group_curve_selection.setVisible(not show)
@SafeProperty(bool)
@Property(bool)
def hide_summary(self) -> bool:
"""SafeProperty for showing the summary."""
"""Property for showing the summary."""
return not self.ui.group_summary.isVisible()
@hide_summary.setter
@@ -133,9 +130,9 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.ui.group_summary.setVisible(not show)
@SafeProperty(bool)
@Property(bool)
def hide_parameters(self) -> bool:
"""SafeProperty for showing the parameters."""
"""Property for showing the parameters."""
return not self.ui.group_parameters.isVisible()
@hide_parameters.setter
@@ -149,7 +146,7 @@ class LMFitDialog(BECWidget, QWidget):
@property
def fit_curve_id(self) -> str:
"""SafeProperty for the currently displayed fit curve_id."""
"""Property for the currently displayed fit curve_id."""
return self._fit_curve_id
@fit_curve_id.setter
@@ -162,7 +159,7 @@ class LMFitDialog(BECWidget, QWidget):
self._fit_curve_id = curve_id
self.selected_fit.emit(curve_id)
@SafeSlot(str)
@Slot(str)
def remove_dap_data(self, curve_id: str):
"""Remove the DAP data for the given curve_id.
@@ -172,7 +169,7 @@ class LMFitDialog(BECWidget, QWidget):
self.summary_data.pop(curve_id, None)
self.refresh_curve_list()
@SafeSlot(str)
@Slot(str)
def select_curve(self, curve_id: str):
"""Select active curve_id in the curve list.
@@ -181,7 +178,7 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.fit_curve_id = curve_id
@SafeSlot(dict, dict)
@Slot(dict, dict)
def update_summary_tree(self, data: dict, metadata: dict):
"""Update the summary tree with the given data.

View File

@@ -16,6 +16,7 @@ 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
@@ -27,6 +28,8 @@ 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",
@@ -361,6 +364,24 @@ 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)

View File

@@ -1,7 +0,0 @@
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"]

View File

@@ -1,275 +0,0 @@
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()

View File

@@ -1,67 +0,0 @@
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()

View File

@@ -1,146 +0,0 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QPushButton,
QTableView,
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 = QTableView()
self._table_view.setModel(self._table_model)
self._table_view.horizontalHeader().setStretchLastSection(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()

View File

@@ -1,196 +0,0 @@
from __future__ import annotations
from decimal import Decimal
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.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
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."""
def __init__(
self,
parent=None,
client=None,
scan_name: str | None = None,
initial_extras: list[list[str]] | None = None,
):
super().__init__(client=client)
QWidget.__init__(self, parent=parent)
self.set_schema(scan_name)
self._layout = QVBoxLayout()
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self.setLayout(self._layout)
self._layout.addWidget(QLabel("<b>Required scan metadata:</b>"))
self._md_grid = QWidget()
self._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._layout.addWidget(QLabel("<b>Additional metadata:</b>"))
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "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, *_):
try:
self._md_schema.model_validate(self.get_full_model_dict())
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
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)
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()

View File

@@ -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
@@ -66,7 +66,7 @@ class TextBox(BECWidget, QWidget):
else:
self.set_html_text(DEFAULT_TEXT)
@SafeSlot(str)
@Slot(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
@SafeSlot(str)
@Slot(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
@SafeProperty(str)
@Property(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)
@SafeProperty(str)
@Property(str)
def html_text(self) -> str:
"""Get the HTML text of the widget.

View File

@@ -6,9 +6,11 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.logpanel.log_panel_plugin import LogPanelPlugin
from bec_widgets.widgets.editors.user_script.user_script_widget_plugin import (
UserScriptWidgetPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(LogPanelPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(UserScriptWidgetPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1,569 @@
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_()

View File

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

View File

@@ -4,36 +4,36 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.editors.user_script.user_script import UserScriptWidget
DOM_XML = """
<ui language='c++'>
<widget class='LogPanel' name='log_panel'>
<widget class='UserScriptWidget' name='user_script_widget'>
</widget>
</ui>
"""
class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class UserScriptWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = LogPanel(parent)
t = UserScriptWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
return "BEC Services"
def icon(self):
return designer_material_icon(LogPanel.ICON_NAME)
return designer_material_icon(UserScriptWidget.ICON_NAME)
def includeFile(self):
return "log_panel"
return "user_script_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -45,10 +45,10 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "LogPanel"
return "UserScriptWidget"
def toolTip(self):
return "Displays a log panel"
return "Dialog for displaying the fit summary and params for LMFit DAP processes"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,3 +0,0 @@
from bec_widgets.widgets.games.minesweeper import Minesweeper
__ALL__ = ["Minesweeper"]

View File

@@ -1,416 +0,0 @@
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_()

View File

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

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.games.minesweeper import Minesweeper
DOM_XML = """
<ui language='c++'>
<widget class='Minesweeper' name='minesweeper'>
</widget>
</ui>
"""
class MinesweeperPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Minesweeper(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Games"
def icon(self):
return designer_material_icon(Minesweeper.ICON_NAME)
def includeFile(self):
return "minesweeper"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Minesweeper"
def toolTip(self):
return "Minesweeper"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,15 +0,0 @@
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.games.minesweeper_plugin import MinesweeperPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MinesweeperPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,571 +0,0 @@
from __future__ import annotations
import pyqtgraph as pg
from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.round_frame import RoundedFrame
from bec_widgets.qt_utils.side_panel import SidePanel
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, SeparatorAction
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.plots_next_gen.setting_menus.axis_settings import AxisSettings
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.mouse_interactions import (
MouseInteractionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.save_state import SaveStateBundle
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
class BECViewBox(pg.ViewBox):
sigPaint = Signal()
def paint(self, painter, opt, widget):
super().paint(painter, opt, widget)
self.sigPaint.emit()
def itemBoundsChanged(self, item):
self._itemBoundsCache.pop(item, None)
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
# check if the call is coming from a mouse-move event
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
return
self._autoRangeNeedsUpdate = True
self.update()
class PlotBase(BECWidget, QWidget):
PLUGIN = False
RPC = False
# Custom Signals
property_changed = Signal(str, object)
crosshair_position_changed = Signal(tuple)
crosshair_position_clicked = Signal(tuple)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_clicked = Signal(tuple)
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
QWidget.__init__(self, parent=parent)
# For PropertyManager identification
self.setObjectName("PlotBase")
self.get_bec_shortcuts()
# Layout Management
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
self.layout_manager = LayoutManagerWidget(parent=self)
# Property Manager
self.state_manager = WidgetStateManager(self)
# Entry Validator
self.entry_validator = EntryValidator(self.dev)
# Base widgets elements
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.init_toolbar()
# PlotItem Addons
self.plot_item.addLegend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._init_ui()
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = RoundedFrame(content_widget=self.plot_widget, theme_update=True)
self.round_plot_widget.apply_theme("dark")
self.layout_manager.add_widget(self.round_plot_widget)
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
self.fps_label.hide()
self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
self.add_side_menus()
# PlotItem ViewBox Signals
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def init_toolbar(self):
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
self.state_export_bundle = SaveStateBundle("state_export", target_widget=self)
# Add elements to toolbar
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
self.toolbar.add_bundle(self.state_export_bundle, target_widget=self)
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
self.toolbar.add_action("separator_0", SeparatorAction(), target_widget=self)
self.toolbar.add_action(
"crosshair",
MaterialIconAction(icon_name="point_scan", tooltip="Show Crosshair", checkable=True),
target_widget=self,
)
self.toolbar.add_action("separator_1", SeparatorAction(), target_widget=self)
self.toolbar.add_action(
"fps_monitor",
MaterialIconAction(icon_name="speed", tooltip="Show FPS Monitor", checkable=True),
target_widget=self,
)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
lambda checked: setattr(self, "enable_fps_monitor", checked)
)
self.toolbar.widgets["crosshair"].action.toggled.connect(self.toggle_crosshair)
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget
axis_setting = AxisSettings(target_widget=self)
self.side_panel.add_menu(
action_id="axis",
icon_name="settings",
tooltip="Show Axis Settings",
widget=axis_setting,
title="Axis Settings",
)
################################################################################
# Toggle UI Elements
################################################################################
@SafeProperty(bool, doc="Show Toolbar")
def enable_toolbar(self) -> bool:
return self.toolbar.isVisible()
@enable_toolbar.setter
def enable_toolbar(self, value: bool):
self.toolbar.setVisible(value)
@SafeProperty(bool, doc="Show Side Panel")
def enable_side_panel(self) -> bool:
return self.side_panel.isVisible()
@enable_side_panel.setter
def enable_side_panel(self, value: bool):
self.side_panel.setVisible(value)
@SafeProperty(bool, doc="Enable the FPS monitor.")
def enable_fps_monitor(self) -> bool:
return self.fps_label.isVisible()
@enable_fps_monitor.setter
def enable_fps_monitor(self, value: bool):
if value and self.fps_monitor is None:
self.hook_fps_monitor()
elif not value and self.fps_monitor is not None:
self.unhook_fps_monitor()
################################################################################
# ViewBox State Signals
################################################################################
def viewbox_state_changed(self):
"""
Emit a signal when the state of the viewbox has changed.
Merges the default pyqtgraphs signal states and also CTRL menu toggles.
"""
viewbox_state = self.plot_item.vb.getState()
# Range Limits
x_min, x_max = viewbox_state["targetRange"][0]
y_min, y_max = viewbox_state["targetRange"][1]
self.property_changed.emit("x_min", x_min)
self.property_changed.emit("x_max", x_max)
self.property_changed.emit("y_min", y_min)
self.property_changed.emit("y_max", y_max)
# Grid Toggles
################################################################################
# Plot Properties
################################################################################
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
"""
property_map = {
"title": self.title,
"x_label": self.x_label,
"y_label": self.y_label,
"x_limits": self.x_limits,
"y_limits": self.y_limits,
"x_grid": self.x_grid,
"y_grid": self.y_grid,
"inner_axes": self.inner_axes,
"outer_axes": self.outer_axes,
"lock_aspect_ratio": self.lock_aspect_ratio,
"auto_range_x": self.auto_range_x,
"auto_range_y": self.auto_range_y,
"x_log": self.x_log,
"y_log": self.y_log,
"legend_label_size": self.legend_label_size,
}
for key, value in kwargs.items():
if key in property_map:
setattr(self, key, value)
else:
logger.warning(f"Property {key} not found.")
@SafeProperty(str, doc="The title of the axes.")
def title(self) -> str:
return self.plot_item.titleLabel.text
@title.setter
def title(self, value: str):
self.plot_item.setTitle(value)
self.property_changed.emit("title", value)
@SafeProperty(str, doc="The text of the x label")
def x_label(self) -> str:
return self.plot_item.getAxis("bottom").labelText
@x_label.setter
def x_label(self, value: str):
self.plot_item.setLabel("bottom", text=value)
self.property_changed.emit("x_label", value)
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
return self.plot_item.getAxis("left").labelText
@y_label.setter
def y_label(self, value: str):
self.plot_item.setLabel("left", text=value)
self.property_changed.emit("y_label", value)
def _tuple_to_qpointf(self, tuple: tuple | list):
"""
Helper function to convert a tuple to a QPointF.
Args:
tuple(tuple|list): Tuple or list of two numbers.
Returns:
QPointF: The tuple converted to a QPointF.
"""
if len(tuple) != 2:
raise ValueError("Limits must be a tuple or list of two numbers.")
min_val, max_val = tuple
if not isinstance(min_val, (int, float)) or not isinstance(max_val, (int, float)):
raise TypeError("Limits must be numbers.")
if min_val > max_val:
raise ValueError("Minimum limit cannot be greater than maximum limit.")
return QPoint(*tuple)
################################################################################
# X limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
# the python properties are used for CLI and API for context dialog settings.
@SafeProperty("QPointF")
def x_limits(self) -> QPointF:
current_lim = self.plot_item.vb.viewRange()[0]
return QPointF(current_lim[0], current_lim[1])
@x_limits.setter
def x_limits(self, value):
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
@property
def x_lim(self) -> tuple:
return (self.x_limits.x(), self.x_limits.y())
@x_lim.setter
def x_lim(self, value):
self.x_limits = value
@property
def x_min(self) -> float:
return self.x_limits.x()
@x_min.setter
def x_min(self, value: float):
self.x_limits = (value, self.x_lim[1])
@property
def x_max(self) -> float:
return self.x_limits.y()
@x_max.setter
def x_max(self, value: float):
self.x_limits = (self.x_lim[0], value)
################################################################################
# Y limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
# the python properties are used for CLI and API for context dialog settings.
@SafeProperty("QPointF")
def y_limits(self) -> QPointF:
current_lim = self.plot_item.vb.viewRange()[1]
return QPointF(current_lim[0], current_lim[1])
@y_limits.setter
def y_limits(self, value):
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
@property
def y_lim(self) -> tuple:
return (self.y_limits.x(), self.y_limits.y())
@y_lim.setter
def y_lim(self, value):
self.y_limits = value
@property
def y_min(self) -> float:
return self.y_limits.x()
@y_min.setter
def y_min(self, value: float):
self.y_limits = (value, self.y_lim[1])
@property
def y_max(self) -> float:
return self.y_limits.y()
@y_max.setter
def y_max(self, value: float):
self.y_limits = (self.y_lim[0], value)
@SafeProperty(bool, doc="Show grid on the x-axis.")
def x_grid(self) -> bool:
return self.plot_item.ctrl.xGridCheck.isChecked()
@x_grid.setter
def x_grid(self, value: bool):
self.plot_item.showGrid(x=value)
self.property_changed.emit("x_grid", value)
@SafeProperty(bool, doc="Show grid on the y-axis.")
def y_grid(self) -> bool:
return self.plot_item.ctrl.yGridCheck.isChecked()
@y_grid.setter
def y_grid(self, value: bool):
self.plot_item.showGrid(y=value)
self.property_changed.emit("y_grid", value)
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
def x_log(self) -> bool:
return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
@x_log.setter
def x_log(self, value: bool):
self.plot_item.setLogMode(x=value)
self.property_changed.emit("x_log", value)
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
def y_log(self) -> bool:
return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
@y_log.setter
def y_log(self, value: bool):
self.plot_item.setLogMode(y=value)
self.property_changed.emit("y_log", value)
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
def outer_axes(self) -> bool:
return self.plot_item.getAxis("top").isVisible()
@outer_axes.setter
def outer_axes(self, value: bool):
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
def inner_axes(self) -> bool:
return self.plot_item.getAxis("bottom").isVisible()
@inner_axes.setter
def inner_axes(self, value: bool):
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self.property_changed.emit("inner_axes", value)
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
def lock_aspect_ratio(self) -> bool:
return bool(self.plot_item.vb.getState()["aspectLocked"])
@lock_aspect_ratio.setter
def lock_aspect_ratio(self, value: bool):
self.plot_item.setAspectLocked(value)
@SafeProperty(bool, doc="Set auto range for the x-axis.")
def auto_range_x(self) -> bool:
return bool(self.plot_item.vb.getState()["autoRange"][0])
@auto_range_x.setter
def auto_range_x(self, value: bool):
self.plot_item.enableAutoRange(x=value)
@SafeProperty(bool, doc="Set auto range for the y-axis.")
def auto_range_y(self) -> bool:
return bool(self.plot_item.vb.getState()["autoRange"][1])
@auto_range_y.setter
def auto_range_y(self, value: bool):
self.plot_item.enableAutoRange(y=value)
@SafeProperty(int, doc="The font size of the legend font.")
def legend_label_size(self) -> int:
if not self.plot_item.legend:
return
scale = self.plot_item.legend.scale() * 9
return scale
@legend_label_size.setter
def legend_label_size(self, value: int):
if not self.plot_item.legend:
return
scale = (
value / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
################################################################################
# FPS Counter
################################################################################
def update_fps_label(self, fps: float) -> None:
"""
Update the FPS label.
Args:
fps(float): The frames per second.
"""
if self.fps_label:
self.fps_label.setText(f"FPS: {fps:.2f}")
def hook_fps_monitor(self):
"""Hook the FPS monitor to the plot."""
if self.fps_monitor is None:
self.fps_monitor = FPSCounter(self.plot_item.vb)
self.fps_label.show()
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
self.update_fps_label(0)
def unhook_fps_monitor(self, delete_label=True):
"""Unhook the FPS monitor from the plot."""
if self.fps_monitor is not None and delete_label:
# Remove Monitor
self.fps_monitor.cleanup()
self.fps_monitor.deleteLater()
self.fps_monitor = None
if self.fps_label is not None:
# Hide Label
self.fps_label.hide()
################################################################################
# Crosshair
################################################################################
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
def unhook_crosshair(self) -> None:
"""Unhook the crosshair from all plots."""
if self.crosshair is not None:
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.cleanup()
self.crosshair.deleteLater()
self.crosshair = None
def toggle_crosshair(self) -> None:
"""Toggle the crosshair on all plots."""
if self.crosshair is None:
return self.hook_crosshair()
self.unhook_crosshair()
@SafeSlot()
def reset(self) -> None:
"""Reset the plot widget."""
if self.crosshair is not None:
self.crosshair.clear_markers()
self.crosshair.update_markers()
def cleanup(self):
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=True)
self.cleanup_pyqtgraph()
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
set_theme("dark")
widget = PlotBase()
widget.show()
# Just some example data and parameters to test
widget.y_grid = True
widget.plot_item.plot([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
sys.exit(app.exec_())

View File

@@ -1,95 +0,0 @@
import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(SettingWidget):
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# This is a settings widget that depends on the target widget
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
self.setObjectName("AxisSettings")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "axis_settings_vertical.ui"), self)
self.target_widget = target_widget
# # Scroll area
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setWidget(form)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
# self.layout.addWidget(self.ui)
self.ui = form
self.connect_all_signals()
if self.target_widget is not None:
self.target_widget.property_changed.connect(self.update_property)
def connect_all_signals(self):
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
]:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@SafeSlot()
def set_property(self, widget: QWidget, value):
"""
Set property of the target widget based on the widget that emitted the signal.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
widget(QWidget): The widget that emitted the signal.
value(): The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
property_name = widget.objectName()
setattr(self.target_widget, property_name, value)
except RuntimeError:
return
@SafeSlot()
def update_property(self, property_name: str, value):
"""
Update the value of the widget based on the property name and value.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
property_name(str): The name of the property to update.
value: The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
widget_to_set = self.ui.findChild(QWidget, property_name)
except RuntimeError:
return
# Block signals to avoid triggering set_property again
was_blocked = widget_to_set.blockSignals(True)
WidgetIO.set_value(widget_to_set, value)
widget_to_set.blockSignals(was_blocked)

View File

@@ -1,256 +0,0 @@
<?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>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>250</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>278</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="2">
<widget class="QComboBox" name="y_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="y_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="x_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,240 +0,0 @@
<?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>241</width>
<height>526</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="4" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="title"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="y_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="ToggleSwitch" name="y_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,88 +0,0 @@
import pyqtgraph as pg
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class MouseInteractionToolbarBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls mouse interactions on a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
drag = MaterialIconAction(
icon_name="drag_pan",
tooltip="Drag Mouse Mode",
checkable=True,
parent=self.target_widget, # or any valid parent
)
rect = MaterialIconAction(
icon_name="frame_inspect",
tooltip="Rectangle Zoom Mode",
checkable=True,
parent=self.target_widget,
)
auto = MaterialIconAction(
icon_name="open_in_full",
tooltip="Autorange Plot",
checkable=False,
parent=self.target_widget,
)
aspect_ratio = MaterialIconAction(
icon_name="aspect_ratio",
tooltip="Lock image aspect ratio",
checkable=True,
parent=self.target_widget,
)
# Add them to the bundle
self.add_action("drag_mode", drag)
self.add_action("rectangle_mode", rect)
self.add_action("auto_range", auto)
self.add_action("aspect_ratio", aspect_ratio)
# Immediately connect signals
drag.action.toggled.connect(self.enable_mouse_pan_mode)
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
aspect_ratio.action.toggled.connect(self.lock_aspect_ratio)
@SafeSlot(bool)
def enable_mouse_rectangle_mode(self, checked: bool):
"""
Enable the rectangle zoom mode on the plot widget.
"""
self.actions["drag_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
@SafeSlot(bool)
def enable_mouse_pan_mode(self, checked: bool):
"""
Enable the pan mode on the plot widget.
"""
self.actions["rectangle_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
@SafeSlot()
def autorange_plot(self):
"""
Enable autorange on the plot widget.
"""
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True
@SafeSlot(bool)
def lock_aspect_ratio(self, checked: bool):
if self.target_widget:
self.target_widget.lock_aspect_ratio = checked

View File

@@ -1,63 +0,0 @@
from pyqtgraph.exporters import MatplotlibExporter
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class PlotExportBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls exporting a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save = MaterialIconAction(
icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget
)
matplotlib = MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
)
# Add them to the bundle
self.add_action("save", save)
self.add_action("matplotlib", matplotlib)
# Immediately connect signals
save.action.triggered.connect(self.export_dialog)
matplotlib.action.triggered.connect(self.matplotlib_dialog)
@SafeSlot()
def export_dialog(self):
"""
Open the export dialog for the plot widget.
"""
if self.target_widget:
scene = self.target_widget.plot_item.scene()
scene.contextMenuItem = self.target_widget.plot_item
scene.showExportDialog()
@SafeSlot()
def matplotlib_dialog(self):
"""
Export the plot widget to Matplotlib.
"""
if self.target_widget:
try:
import matplotlib as mpl
MatplotlibExporter(self.target_widget.plot_item).export()
except:
warning_util = WarningPopupUtility()
warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return

View File

@@ -1,48 +0,0 @@
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class SaveStateBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls saving the state of the widget.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save_state = MaterialIconAction(
icon_name="download", tooltip="Save Widget State", parent=self.target_widget
)
load_state = MaterialIconAction(
icon_name="upload", tooltip="Load Widget State", parent=self.target_widget
)
# Add them to the bundle
self.add_action("save", save_state)
self.add_action("matplotlib", load_state)
# Immediately connect signals
save_state.action.triggered.connect(self.save_state_dialog)
load_state.action.triggered.connect(self.load_state_dialog)
@SafeSlot()
def save_state_dialog(self):
"""
Open the export dialog to save a state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.save_state()
@SafeSlot()
def load_state_dialog(self):
"""
Load a saved state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.load_state()

View File

@@ -124,16 +124,41 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
self.tree = QTreeWidget(self)
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
# self.tree.setStyleSheet(
# "QTreeWidget::item:!selected "
# "{ "
# "border: 1px solid gainsboro; "
# "border-left: none; "
# "border-top: none; "
# "}"
# "QTreeWidget::item:selected {}"
# )
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"QTreeWidget::item:!selected { "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"} "
"QTreeWidget::item:selected {} "
"QTreeView::branch { "
"border-image: none; "
"background: transparent; "
"} "
"QTreeView::branch:has-siblings:!adjoins-item { "
"border-image: none; "
"} "
"QTreeView::branch:has-children:!has-siblings:closed, "
"QTreeView::branch:closed:has-children:has-siblings { "
"border-image: none; "
"} "
"QTreeView::branch:open:has-children:!has-siblings, "
"QTreeView::branch:open:has-children:has-siblings { "
"border-image: none; "
"}"
"QTreeWidget::item:selected {}"
)
# self.tree.setRootIsDecorated(False)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:

View File

@@ -23,7 +23,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "done_outline"
RUNNING = "check_circle"
BUSY = "progress_activity"
IDLE = "progress_activity"
ERROR = "emergency_home"

View File

@@ -1,3 +0,0 @@
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
__ALL__ = ["LogPanel"]

View File

@@ -1,58 +0,0 @@
""" Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations
import re
from collections import deque
from typing import Callable, Iterator
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
LineFormatter = Callable[[LogMessage], str]
LineFilter = Callable[[LogMessage], bool] | None
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def replace_escapes(s: str):
s = ANSI_ESCAPE_REGEX.sub("", s)
return s.replace(" ", "&nbsp;").replace("\n", "<br />").replace("\t", " ")
def level_filter(msg: LogMessage, thresh: int):
return LogLevel[msg.content["log_type"].upper()].value >= thresh
def noop_format(line: LogMessage):
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
return replace_escapes(_textline.strip()) + "<br />"
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
return f'<font color="{color}">{noop_format(line)}</font>'
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
def _formatter(data: deque[LogMessage]):
if line_filter is not None:
return (line_format(line) for line in data if line_filter(line))
else:
return (line_format(line) for line in data)
return _formatter
def log_txt(line):
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
def log_time(line):
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
def log_svc(line):
return line.log_msg["service_name"]

View File

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

View File

@@ -1,529 +0,0 @@
""" Module for a LogPanel widget to display BEC log messages """
from __future__ import annotations
import operator
import os
import re
from collections import deque
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from bec_lib.client import BECClient
from bec_lib.connector import ConnectorBase
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.colors import get_theme_palette, set_theme
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
from bec_widgets.widgets.utility.logpanel._util import (
LineFilter,
LineFormatter,
LinesHtmlFormatter,
create_formatter,
level_filter,
log_svc,
log_time,
log_txt,
noop_format,
simple_color_format,
)
if TYPE_CHECKING:
from PySide6.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# TODO: improve log color handling
DEFAULT_LOG_COLORS = {
LogLevel.INFO: "#FFFFFF",
LogLevel.SUCCESS: "#00FF00",
LogLevel.WARNING: "#FFCC00",
LogLevel.ERROR: "#FF0000",
LogLevel.DEBUG: "#0000CC",
}
class BecLogsQueue:
"""Manages getting logs from BEC Redis and formatting them for display"""
def __init__(
self,
conn: ConnectorBase,
new_message_signal: SignalInstance,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
) -> None:
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._conn = conn
self._new_message_signal: SignalInstance | None = new_message_signal
self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
self._log_level: str | None = None
self._search_query: Pattern | str | None = None
self._selected_services: set[str] | None = None
self._set_formatter_and_update_filter(line_formatter)
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
def disconnect(self):
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
self._new_message_signal.disconnect()
def _process_incoming_log_msg(self, msg: dict):
try:
_msg: LogMessage = msg["data"]
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
if self._new_message_signal:
self._new_message_signal.emit()
except Exception:
logger.warning("Error in LogPanel incoming message callback!")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
self._line_formatter: LineFormatter = line_formatter
self._queue_formatter: LinesHtmlFormatter = create_formatter(
self._line_formatter, self.filter
)
def _combine_filters(self, *args: LineFilter):
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
def _create_re_filter(self) -> LineFilter:
if self._search_query is None:
return None
elif isinstance(self._search_query, str):
return lambda line: self._search_query in log_txt(line)
return lambda line: self._search_query.match(log_txt(line)) is not None
def _create_service_filter(self):
return (
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
)
def _create_timestamp_filter(self) -> LineFilter:
s, e = self._timestamp_start, self._timestamp_end
if s is e is None:
return lambda msg: True
def _time_filter(msg):
msg_time = log_time(msg)
if s is None:
return msg_time <= e
if e is None:
return s <= msg_time
return s <= msg_time <= e
return _time_filter
@property
def filter(self) -> LineFilter:
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
self._create_re_filter(),
self._create_timestamp_filter(),
self._create_service_filter(),
)
def update_level_filter(self, level: str):
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
return
self._log_level = level
self._set_formatter_and_update_filter(self._line_formatter)
def update_search_filter(self, search_query: Pattern | str | None = None):
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
self._timestamp_start = start
self._timestamp_end = end
self._set_formatter_and_update_filter(self._line_formatter)
def update_service_filter(self, services: set[str]):
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter):
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str:
return "\n".join(self._queue_formatter(self._data.copy()))
def format_new(self):
res = "\n".join(self._display_queue)
self._display_queue = deque([], self._max_length)
return res
def clear_logs(self):
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self):
self._data = deque(
item["data"]
for item in self._conn.xread(
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
)
)
def unique_service_names_from_history(self) -> set[str]:
return set(msg.log_msg["service_name"] for msg in self._data)
class LogPanelToolbar(QWidget):
services_selected: pyqtBoundSignal = Signal(set)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
# in unix time
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set()
self._services_selected: set[str] | None = None
self.layout = QHBoxLayout(self) # type: ignore
self.service_choice_button = QPushButton("Select services", self)
self.layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.filter_level_dropdown = self._log_level_box()
self.layout.addWidget(self.filter_level_dropdown)
self.clear_button = QPushButton("Clear all", self)
self.layout.addWidget(self.clear_button)
self.fetch_button = QPushButton("Fetch history", self)
self.layout.addWidget(self.fetch_button)
self._string_search_box()
self.timerange_button = QPushButton("Set time range", self)
self.layout.addWidget(self.timerange_button)
@property
def time_start(self):
return self._timestamp_start
@property
def time_end(self):
return self._timestamp_end
def _string_search_box(self):
self.layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit()
self.layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: "))
self.regex_enabled = QCheckBox()
self.layout.addWidget(self.regex_enabled)
self.update_re_button = QPushButton("Update search", self)
self.layout.addWidget(self.update_re_button)
def _log_level_box(self):
box = QComboBox()
box.setToolTip("Display logs with equal or greater significance to the selected level.")
[box.addItem(l.name) for l in LogLevel]
return box
def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start":
return self._timestamp_start
elif selection_type == "end":
return self._timestamp_end
else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self)
self._dt_dialog.setWindowTitle("Time range selection")
layout = QVBoxLayout()
self._dt_dialog.setLayout(layout)
label_start = QLabel(parent=self._dt_dialog)
label_end = QLabel(parent=self._dt_dialog)
def date_button_set(selection_type: Literal["start", "end"], label: QLabel):
dt = self._current_ts(selection_type)
_layout = QHBoxLayout()
layout.addLayout(_layout)
date_button = QPushButton(f"Time {selection_type}", parent=self._dt_dialog)
_layout.addWidget(date_button)
label.setText(dt.toString() if dt else "not selected")
_layout.addWidget(label)
date_button.clicked.connect(partial(self._open_cal_dialog, selection_type, label))
date_clear_button = QPushButton("clear", parent=self._dt_dialog)
date_clear_button.clicked.connect(
lambda: (
partial(self._update_time, selection_type)(None),
label.setText("not selected"),
)
)
_layout.addWidget(date_clear_button)
for v in [("start", label_start), ("end", label_end)]:
date_button_set(*v)
close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept)
layout.addWidget(close_button)
self._dt_dialog.exec()
self._dt_dialog.deleteLater()
def _open_cal_dialog(self, selection_type: Literal["start", "end"], label: QLabel):
"""Open dialog window for timestamp filter selection"""
dt = self._current_ts(selection_type) or QDateTime.currentDateTime()
label.setText(dt.toString() if dt else "not selected")
if selection_type == "start":
self._timestamp_start = dt
else:
self._timestamp_end = dt
self._cal_dialog = QDialog(self)
self._cal_dialog.setWindowTitle(f"Select time range {selection_type}")
layout = QVBoxLayout()
self._cal_dialog.setLayout(layout)
cal = QDateTimeEdit(parent=self._cal_dialog)
cal.setCalendarPopup(True)
cal.setDateTime(dt)
cal.setDisplayFormat("yyyy-MM-dd HH:mm:ss.zzz")
cal.dateTimeChanged.connect(partial(self._update_time, selection_type))
layout.addWidget(cal)
close_button = QPushButton("Close", parent=self._cal_dialog)
close_button.clicked.connect(self._cal_dialog.accept)
layout.addWidget(close_button)
self._cal_dialog.exec()
self._cal_dialog.deleteLater()
def _update_time(self, selection_type: Literal["start", "end"], dt: QDateTime | None):
if selection_type == "start":
self._timestamp_start = dt
else:
self._timestamp_end = dt
@SafeSlot(dict, set)
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
self._services_selected = self._unique_service_names
@SafeSlot()
def _open_service_filter_dialog(self):
if len(self._unique_service_names) == 0 or self._services_selected is None:
return
self._svc_dialog = QDialog(self)
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
layout = QVBoxLayout()
self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout(parent=self._svc_dialog)
layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState):
if checked == Qt.CheckState.Checked:
self._services_selected.add(name)
else:
if name in self._services_selected:
self._services_selected.remove(name)
self.services_selected.emit(self._services_selected)
for i, svc in enumerate(self._unique_service_names):
service_cb_grid.addWidget(QLabel(svc, parent=self._svc_dialog), i, 0)
cb = QCheckBox(parent=self._svc_dialog)
cb.setChecked(svc in self._services_selected)
cb.checkStateChanged.connect(partial(check_box, svc))
service_cb_grid.addWidget(cb, i, 1)
close_button = QPushButton("Close", parent=self._svc_dialog)
close_button.clicked.connect(self._svc_dialog.accept)
layout.addWidget(close_button)
self._svc_dialog.exec()
self._svc_dialog.deleteLater()
class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
_new_messages = Signal()
service_list_update = Signal(dict, set)
def __init__(
self,
parent=None,
client: BECClient | None = None,
service_status: BECServiceStatusMixin | None = None,
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
self.client.connector, # type: ignore
new_message_signal=self._new_messages,
line_formatter=partial(simple_color_format, colors=self._colors),
)
self.toolbar = LogPanelToolbar(parent=parent)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
self.toolbar_area.setWidget(self.toolbar)
self.layout.addWidget(self.toolbar_area)
self.toolbar.clear_button.clicked.connect(self._on_clear)
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self._new_messages.connect(self._on_append)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
self.service_list_update.connect(self.toolbar.service_list_update)
self.toolbar.services_selected.connect(self._update_service_filter)
self.text_box_text_edit.setFont(QFont("monospace", 12))
self.text_box_text_edit.setHtml("")
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._connect_to_theme_change()
@SafeSlot(set)
def _update_service_filter(self, services: set[str]):
self._log_manager.update_service_filter(services)
self._on_redraw()
@SafeSlot(dict, dict)
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
self.service_list_update.emit(
services_info, self._log_manager.unique_service_names_from_history()
)
@SafeSlot()
def _choose_datetime(self):
self.toolbar._open_datetime_dialog()
self._set_time_filter()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
def _update_colors(self):
self._colors = DEFAULT_LOG_COLORS.copy()
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
def _cursor_to_end(self):
c = self.text_box_text_edit.textCursor()
c.movePosition(c.MoveOperation.End)
self.text_box_text_edit.setTextCursor(c)
@SafeSlot()
@SafeSlot(str)
def _on_redraw(self, *_):
self._update_colors()
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_append(self):
self._cursor_to_end()
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
@SafeSlot()
def _on_clear(self):
self._log_manager.clear_logs()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
@SafeSlot(Qt.CheckState)
def _on_re_update(self, *_):
if self.toolbar.regex_enabled.isChecked():
try:
search_query = re.compile(self.toolbar.search_textbox.text())
except Exception as e:
logger.warning(f"Failed to compile search regex with error {e}")
search_query = None
logger.info(f"Setting LogPanel search regex to {search_query}")
else:
search_query = self.toolbar.search_textbox.text()
logger.info(f'Setting LogPanel search string to "{search_query}"')
self._log_manager.update_search_filter(search_query)
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_fetch(self):
self._log_manager.fetch_history()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(str)
def _set_level_filter(self, level: str):
self._log_manager.update_level_filter(level)
self._on_redraw()
@SafeSlot()
def _set_time_filter(self):
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
self._on_redraw()
def cleanup(self):
self._service_status.cleanup()
self._log_manager.disconnect()
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 = LogPanel()
widget.show()
sys.exit(app.exec())

View File

@@ -75,7 +75,7 @@ class SpinnerWidget(QWidget):
proportion = 1 / 4
angle_span = int(proportion * 360 * 16)
angle_span += angle_span * ease_in_out_sine(self.time / self.duration)
painter.drawArc(adjusted_rect, int(self.angle * 16), int(angle_span))
painter.drawArc(adjusted_rect, self.angle * 16, int(angle_span))
painter.end()
def closeEvent(self, event):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -9,24 +9,25 @@ Before installing BEC Widgets, please ensure the following requirements are met:
**Standard Installation**
To install BEC Widgets using the pip package manager, execute the following command in your terminal for getting the
default PySide6 version into your python environment for BEC:
To install BEC Widgets using the pip package manager, execute the following command in your terminal for getting the default PyQT6 version into your python environment for BEC:
```bash
pip install 'bec_widgets[pyqt6]'
```
In case you want to use Pyside6, you can install it by using the following command:
```bash
pip install 'bec_widgets[pyside6]'
```
**Troubleshooting**
If you encounter issues during installation, particularly with Qt, try purging the pip cache:
If you encounter issues during installation, particularly with PyQt, try purging the pip cache:
```bash
pip cache purge
```
This can resolve conflicts or issues with package installations.
```{warning}
At the moment PyQt6 is no longer officially supported by BEC Widgets due to incompatibilities with Qt Designer. Please use PySide6 instead.
```

View File

@@ -21,10 +21,6 @@ The `DeviceComboBox` widget offers a dropdown interface for device selection, pr
- **Dropdown Selection (ComboBox)**: The `DeviceComboBox` widget displays devices in a dropdown list, making selection straightforward.
- **QtDesigner Integration**: Both widgets can be added as custom widgets in `QtDesigner` or instantiated directly in code.
## Screenshot
```{figure} /assets/widget_screenshots/device_inputs.png
```
````
````{tab} Examples

View File

@@ -1,11 +0,0 @@
(user.widgets.games)=
# Game widgets
To provide some entertainment during long nights at the beamline, there are game widgets available. Well, only one, so far.
## Minesweeper
![Minesweeper](./minesweeper.png)
The classic game Minesweeper. You may select from three different levels. The game can be ended or reset by clicking on the icon in the top-centre (the robot in the screenshot).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,39 +0,0 @@
(user.widgets.log_panel)=
# LogPanel widget
The LogPanel widget can be used to view logs:
![LogPanel](./logpanel.png)
It automatically subscribes to log updates. You can fetch the log history with the "Fetch history" button.
## Filtering based on log level
If you select a dropdown box, only logs of that priority level or higher will be displayed:
![LogPanel](./logpanel_level.png)
## Filtering based on a search string
If you type in a search string into the box in the toolbar, and hit enter or press the update button, that filter will be applied:
![LogPanel](./logpanel_text.png)
This search uses the [Python regular expression syntax](https://docs.python.org/3/library/re.html) if the checkbox for this option is selected:
![LogPanel](./logpanel_regex.png)
## Filtering based on time range
You may filter the logs to those occurring within a given time range.
![LogPanel](./logpanel_timerange.png)
## Filtering based on service
You can select which services to show logs from.
![LogPanel](./logpanel_services.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

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