bec-widgets 1.11.0__py3-none-any.whl → 1.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- .gitlab-ci.yml +1 -0
- CHANGELOG.md +5712 -0
- PKG-INFO +4 -3
- bec_widgets/cli/auto_updates.py +45 -61
- bec_widgets/cli/client.py +15 -2
- bec_widgets/cli/client_utils.py +142 -198
- bec_widgets/cli/generate_cli.py +2 -2
- bec_widgets/cli/rpc/__init__.py +0 -0
- bec_widgets/cli/rpc/rpc_base.py +177 -0
- bec_widgets/cli/server.py +66 -29
- bec_widgets/qt_utils/error_popups.py +47 -3
- bec_widgets/tests/utils.py +8 -0
- bec_widgets/utils/bec_connector.py +1 -1
- bec_widgets/utils/widget_io.py +85 -5
- bec_widgets/widgets/containers/dock/dock.py +1 -1
- bec_widgets/widgets/containers/dock/dock_area.py +40 -2
- bec_widgets/widgets/containers/layout_manager/layout_manager.py +1 -1
- bec_widgets/widgets/containers/main_window/main_window.py +33 -1
- {bec_widgets-1.11.0.dist-info → bec_widgets-1.13.0.dist-info}/METADATA +4 -3
- {bec_widgets-1.11.0.dist-info → bec_widgets-1.13.0.dist-info}/RECORD +26 -24
- {bec_widgets-1.11.0.dist-info → bec_widgets-1.13.0.dist-info}/WHEEL +1 -1
- pyproject.toml +2 -2
- /bec_widgets/cli/{rpc_register.py → rpc/rpc_register.py} +0 -0
- /bec_widgets/cli/{rpc_wigdet_handler.py → rpc/rpc_widget_handler.py} +0 -0
- {bec_widgets-1.11.0.dist-info → bec_widgets-1.13.0.dist-info}/entry_points.txt +0 -0
- {bec_widgets-1.11.0.dist-info → bec_widgets-1.13.0.dist-info}/licenses/LICENSE +0 -0
bec_widgets/cli/generate_cli.py
CHANGED
@@ -35,7 +35,7 @@ from __future__ import annotations
|
|
35
35
|
import enum
|
36
36
|
from typing import Literal, Optional, overload
|
37
37
|
|
38
|
-
from bec_widgets.cli.
|
38
|
+
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
39
39
|
|
40
40
|
# pylint: skip-file"""
|
41
41
|
|
@@ -84,7 +84,7 @@ class Widgets(str, enum.Enum):
|
|
84
84
|
# Generate the content
|
85
85
|
if cls.__name__ == "BECDockArea":
|
86
86
|
self.content += f"""
|
87
|
-
class {class_name}(RPCBase
|
87
|
+
class {class_name}(RPCBase):"""
|
88
88
|
else:
|
89
89
|
self.content += f"""
|
90
90
|
class {class_name}(RPCBase):"""
|
File without changes
|
@@ -0,0 +1,177 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import threading
|
4
|
+
import uuid
|
5
|
+
from functools import wraps
|
6
|
+
from typing import TYPE_CHECKING
|
7
|
+
|
8
|
+
from bec_lib.client import BECClient
|
9
|
+
from bec_lib.endpoints import MessageEndpoints
|
10
|
+
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
11
|
+
|
12
|
+
import bec_widgets.cli.client as client
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from bec_lib import messages
|
16
|
+
from bec_lib.connector import MessageObject
|
17
|
+
else:
|
18
|
+
messages = lazy_import("bec_lib.messages")
|
19
|
+
# from bec_lib.connector import MessageObject
|
20
|
+
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
21
|
+
|
22
|
+
|
23
|
+
def rpc_call(func):
|
24
|
+
"""
|
25
|
+
A decorator for calling a function on the server.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
func: The function to call.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
The result of the function call.
|
32
|
+
"""
|
33
|
+
|
34
|
+
@wraps(func)
|
35
|
+
def wrapper(self, *args, **kwargs):
|
36
|
+
# we could rely on a strict type check here, but this is more flexible
|
37
|
+
# moreover, it would anyway crash for objects...
|
38
|
+
out = []
|
39
|
+
for arg in args:
|
40
|
+
if hasattr(arg, "name"):
|
41
|
+
arg = arg.name
|
42
|
+
out.append(arg)
|
43
|
+
args = tuple(out)
|
44
|
+
for key, val in kwargs.items():
|
45
|
+
if hasattr(val, "name"):
|
46
|
+
kwargs[key] = val.name
|
47
|
+
if not self.gui_is_alive():
|
48
|
+
raise RuntimeError("GUI is not alive")
|
49
|
+
return self._run_rpc(func.__name__, *args, **kwargs)
|
50
|
+
|
51
|
+
return wrapper
|
52
|
+
|
53
|
+
|
54
|
+
class RPCResponseTimeoutError(Exception):
|
55
|
+
"""Exception raised when an RPC response is not received within the expected time."""
|
56
|
+
|
57
|
+
def __init__(self, request_id, timeout):
|
58
|
+
super().__init__(
|
59
|
+
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
60
|
+
)
|
61
|
+
|
62
|
+
|
63
|
+
class RPCBase:
|
64
|
+
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
65
|
+
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
66
|
+
self._config = config if config is not None else {}
|
67
|
+
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
68
|
+
self._parent = parent
|
69
|
+
self._msg_wait_event = threading.Event()
|
70
|
+
self._rpc_response = None
|
71
|
+
super().__init__()
|
72
|
+
# print(f"RPCBase: {self._gui_id}")
|
73
|
+
|
74
|
+
def __repr__(self):
|
75
|
+
type_ = type(self)
|
76
|
+
qualname = type_.__qualname__
|
77
|
+
return f"<{qualname} object at {hex(id(self))}>"
|
78
|
+
|
79
|
+
@property
|
80
|
+
def _root(self):
|
81
|
+
"""
|
82
|
+
Get the root widget. This is the BECFigure widget that holds
|
83
|
+
the anchor gui_id.
|
84
|
+
"""
|
85
|
+
parent = self
|
86
|
+
# pylint: disable=protected-access
|
87
|
+
while parent._parent is not None:
|
88
|
+
parent = parent._parent
|
89
|
+
return parent
|
90
|
+
|
91
|
+
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
92
|
+
"""
|
93
|
+
Run the RPC call.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
method: The method to call.
|
97
|
+
args: The arguments to pass to the method.
|
98
|
+
wait_for_rpc_response: Whether to wait for the RPC response.
|
99
|
+
kwargs: The keyword arguments to pass to the method.
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
The result of the RPC call.
|
103
|
+
"""
|
104
|
+
request_id = str(uuid.uuid4())
|
105
|
+
rpc_msg = messages.GUIInstructionMessage(
|
106
|
+
action=method,
|
107
|
+
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
108
|
+
metadata={"request_id": request_id},
|
109
|
+
)
|
110
|
+
|
111
|
+
# pylint: disable=protected-access
|
112
|
+
receiver = self._root._gui_id
|
113
|
+
if wait_for_rpc_response:
|
114
|
+
self._rpc_response = None
|
115
|
+
self._msg_wait_event.clear()
|
116
|
+
self._client.connector.register(
|
117
|
+
MessageEndpoints.gui_instruction_response(request_id),
|
118
|
+
cb=self._on_rpc_response,
|
119
|
+
parent=self,
|
120
|
+
)
|
121
|
+
|
122
|
+
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
123
|
+
|
124
|
+
if wait_for_rpc_response:
|
125
|
+
try:
|
126
|
+
finished = self._msg_wait_event.wait(timeout)
|
127
|
+
if not finished:
|
128
|
+
raise RPCResponseTimeoutError(request_id, timeout)
|
129
|
+
finally:
|
130
|
+
self._msg_wait_event.clear()
|
131
|
+
self._client.connector.unregister(
|
132
|
+
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
133
|
+
)
|
134
|
+
# get class name
|
135
|
+
if not self._rpc_response.accepted:
|
136
|
+
raise ValueError(self._rpc_response.message["error"])
|
137
|
+
msg_result = self._rpc_response.message.get("result")
|
138
|
+
self._rpc_response = None
|
139
|
+
return self._create_widget_from_msg_result(msg_result)
|
140
|
+
|
141
|
+
@staticmethod
|
142
|
+
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
143
|
+
msg = msg.value
|
144
|
+
parent._msg_wait_event.set()
|
145
|
+
parent._rpc_response = msg
|
146
|
+
|
147
|
+
def _create_widget_from_msg_result(self, msg_result):
|
148
|
+
if msg_result is None:
|
149
|
+
return None
|
150
|
+
if isinstance(msg_result, list):
|
151
|
+
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
152
|
+
if isinstance(msg_result, dict):
|
153
|
+
if "__rpc__" not in msg_result:
|
154
|
+
return {
|
155
|
+
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
156
|
+
}
|
157
|
+
cls = msg_result.pop("widget_class", None)
|
158
|
+
msg_result.pop("__rpc__", None)
|
159
|
+
|
160
|
+
if not cls:
|
161
|
+
return msg_result
|
162
|
+
|
163
|
+
cls = getattr(client, cls)
|
164
|
+
# print(msg_result)
|
165
|
+
return cls(parent=self, **msg_result)
|
166
|
+
return msg_result
|
167
|
+
|
168
|
+
def gui_is_alive(self):
|
169
|
+
"""
|
170
|
+
Check if the GUI is alive.
|
171
|
+
"""
|
172
|
+
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
173
|
+
if heart is None:
|
174
|
+
return False
|
175
|
+
if heart.status == messages.BECStatus.RUNNING:
|
176
|
+
return True
|
177
|
+
return False
|
bec_widgets/cli/server.py
CHANGED
@@ -1,9 +1,11 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import functools
|
3
4
|
import json
|
4
5
|
import signal
|
5
6
|
import sys
|
6
|
-
|
7
|
+
import types
|
8
|
+
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
7
9
|
from typing import Union
|
8
10
|
|
9
11
|
from bec_lib.endpoints import MessageEndpoints
|
@@ -12,7 +14,8 @@ from bec_lib.service_config import ServiceConfig
|
|
12
14
|
from bec_lib.utils.import_utils import lazy_import
|
13
15
|
from qtpy.QtCore import Qt, QTimer
|
14
16
|
|
15
|
-
from bec_widgets.cli.rpc_register import RPCRegister
|
17
|
+
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
18
|
+
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
16
19
|
from bec_widgets.utils import BECDispatcher
|
17
20
|
from bec_widgets.utils.bec_connector import BECConnector
|
18
21
|
from bec_widgets.widgets.containers.dock import BECDockArea
|
@@ -23,6 +26,27 @@ messages = lazy_import("bec_lib.messages")
|
|
23
26
|
logger = bec_logger.logger
|
24
27
|
|
25
28
|
|
29
|
+
@contextmanager
|
30
|
+
def rpc_exception_hook(err_func):
|
31
|
+
"""This context replaces the popup message box for error display with a specific hook"""
|
32
|
+
# get error popup utility singleton
|
33
|
+
popup = ErrorPopupUtility()
|
34
|
+
# save current setting
|
35
|
+
old_exception_hook = popup.custom_exception_hook
|
36
|
+
|
37
|
+
# install err_func, if it is a callable
|
38
|
+
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
39
|
+
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
40
|
+
|
41
|
+
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
|
42
|
+
|
43
|
+
try:
|
44
|
+
yield popup
|
45
|
+
finally:
|
46
|
+
# restore state of error popup utility singleton
|
47
|
+
popup.custom_exception_hook = old_exception_hook
|
48
|
+
|
49
|
+
|
26
50
|
class BECWidgetsCLIServer:
|
27
51
|
|
28
52
|
def __init__(
|
@@ -57,18 +81,19 @@ class BECWidgetsCLIServer:
|
|
57
81
|
def on_rpc_update(self, msg: dict, metadata: dict):
|
58
82
|
request_id = metadata.get("request_id")
|
59
83
|
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
84
|
+
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
85
|
+
try:
|
86
|
+
obj = self.get_object_from_config(msg["parameter"])
|
87
|
+
method = msg["action"]
|
88
|
+
args = msg["parameter"].get("args", [])
|
89
|
+
kwargs = msg["parameter"].get("kwargs", {})
|
90
|
+
res = self.run_rpc(obj, method, args, kwargs)
|
91
|
+
except Exception as e:
|
92
|
+
logger.error(f"Error while executing RPC instruction: {e}")
|
93
|
+
self.send_response(request_id, False, {"error": str(e)})
|
94
|
+
else:
|
95
|
+
logger.debug(f"RPC instruction executed successfully: {res}")
|
96
|
+
self.send_response(request_id, True, {"result": res})
|
72
97
|
|
73
98
|
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
74
99
|
self.client.connector.set_and_publish(
|
@@ -181,14 +206,8 @@ def main():
|
|
181
206
|
|
182
207
|
import bec_widgets
|
183
208
|
|
184
|
-
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
185
|
-
if __name__ != "__main__":
|
186
|
-
# if not running as main, set the log level to critical
|
187
|
-
# pylint: disable=protected-access
|
188
|
-
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
189
|
-
|
190
209
|
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
191
|
-
parser.add_argument("--id", type=str, help="The id of the server")
|
210
|
+
parser.add_argument("--id", type=str, default="test", help="The id of the server")
|
192
211
|
parser.add_argument(
|
193
212
|
"--gui_class",
|
194
213
|
type=str,
|
@@ -199,10 +218,20 @@ def main():
|
|
199
218
|
|
200
219
|
args = parser.parse_args()
|
201
220
|
|
202
|
-
if args.
|
203
|
-
|
204
|
-
|
221
|
+
if args.hide:
|
222
|
+
# if we start hidden, it means we are under control of the client
|
223
|
+
# -> set the log level to critical to not see all the messages
|
224
|
+
# pylint: disable=protected-access
|
225
|
+
# bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
226
|
+
bec_logger.level = bec_logger.LOGLEVEL.CRITICAL
|
227
|
+
else:
|
228
|
+
# verbose log
|
229
|
+
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
230
|
+
|
231
|
+
if args.gui_class == "BECDockArea":
|
205
232
|
gui_class = BECDockArea
|
233
|
+
elif args.gui_class == "BECFigure":
|
234
|
+
gui_class = BECFigure
|
206
235
|
else:
|
207
236
|
print(
|
208
237
|
"Please specify a valid gui_class to run. Use -h for help."
|
@@ -213,8 +242,10 @@ def main():
|
|
213
242
|
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
|
214
243
|
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
215
244
|
app = QApplication(sys.argv)
|
216
|
-
|
217
|
-
|
245
|
+
# set close on last window, only if not under control of client ;
|
246
|
+
# indeed, Qt considers a hidden window a closed window, so if all windows
|
247
|
+
# are hidden by default it exits
|
248
|
+
app.setQuitOnLastWindowClosed(not args.hide)
|
218
249
|
module_path = os.path.dirname(bec_widgets.__file__)
|
219
250
|
icon = QIcon()
|
220
251
|
icon.addFile(
|
@@ -222,6 +253,8 @@ def main():
|
|
222
253
|
size=QSize(48, 48),
|
223
254
|
)
|
224
255
|
app.setWindowIcon(icon)
|
256
|
+
# store gui id within QApplication object, to make it available to all widgets
|
257
|
+
app.gui_id = args.id
|
225
258
|
|
226
259
|
server = _start_server(args.id, gui_class, args.config)
|
227
260
|
|
@@ -233,7 +266,6 @@ def main():
|
|
233
266
|
|
234
267
|
gui = server.gui
|
235
268
|
win.setCentralWidget(gui)
|
236
|
-
win.resize(800, 600)
|
237
269
|
if not args.hide:
|
238
270
|
win.show()
|
239
271
|
|
@@ -242,6 +274,12 @@ def main():
|
|
242
274
|
def sigint_handler(*args):
|
243
275
|
# display message, for people to let it terminate gracefully
|
244
276
|
print("Caught SIGINT, exiting")
|
277
|
+
# first hide all top level windows
|
278
|
+
# this is to discriminate the cases between "user clicks on [X]"
|
279
|
+
# (which should be filtered, to not close -see BECDockArea-)
|
280
|
+
# or "app is asked to close"
|
281
|
+
for window in app.topLevelWidgets():
|
282
|
+
window.hide() # so, we know we can exit because it is hidden
|
245
283
|
app.quit()
|
246
284
|
|
247
285
|
signal.signal(signal.SIGINT, sigint_handler)
|
@@ -250,6 +288,5 @@ def main():
|
|
250
288
|
sys.exit(app.exec())
|
251
289
|
|
252
290
|
|
253
|
-
if __name__ == "__main__":
|
254
|
-
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
|
291
|
+
if __name__ == "__main__":
|
255
292
|
main()
|
@@ -2,10 +2,46 @@ import functools
|
|
2
2
|
import sys
|
3
3
|
import traceback
|
4
4
|
|
5
|
-
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
5
|
+
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
6
6
|
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
7
7
|
|
8
8
|
|
9
|
+
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs):
|
10
|
+
"""
|
11
|
+
Decorator to create a Qt Property with a safe setter that won't crash Designer on errors.
|
12
|
+
Behaves similarly to SafeSlot, but for properties.
|
13
|
+
|
14
|
+
Args:
|
15
|
+
prop_type: The property type (e.g., str, bool, "QStringList", etc.)
|
16
|
+
popup_error (bool): If True, show popup on error, otherwise just handle it silently.
|
17
|
+
*prop_args, **prop_kwargs: Additional arguments and keyword arguments accepted by Property.
|
18
|
+
"""
|
19
|
+
|
20
|
+
def decorator(getter):
|
21
|
+
class PropertyWrapper:
|
22
|
+
def __init__(self, getter_func):
|
23
|
+
self.getter_func = getter_func
|
24
|
+
|
25
|
+
def setter(self, setter_func):
|
26
|
+
@functools.wraps(setter_func)
|
27
|
+
def safe_setter(self_, value):
|
28
|
+
try:
|
29
|
+
return setter_func(self_, value)
|
30
|
+
except Exception:
|
31
|
+
if popup_error:
|
32
|
+
ErrorPopupUtility().custom_exception_hook(
|
33
|
+
*sys.exc_info(), popup_error=True
|
34
|
+
)
|
35
|
+
else:
|
36
|
+
return
|
37
|
+
|
38
|
+
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
|
39
|
+
|
40
|
+
return PropertyWrapper(getter)
|
41
|
+
|
42
|
+
return decorator
|
43
|
+
|
44
|
+
|
9
45
|
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
10
46
|
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
11
47
|
to the passed function, to display errors instead of potentially raising an exception
|
@@ -91,6 +127,12 @@ class _ErrorPopupUtility(QObject):
|
|
91
127
|
msg.setMinimumHeight(400)
|
92
128
|
msg.exec_()
|
93
129
|
|
130
|
+
def show_property_error(self, title, message, widget):
|
131
|
+
"""
|
132
|
+
Show a property-specific error message.
|
133
|
+
"""
|
134
|
+
self.error_occurred.emit(title, message, widget)
|
135
|
+
|
94
136
|
def format_traceback(self, traceback_message: str) -> str:
|
95
137
|
"""
|
96
138
|
Format the traceback message to be displayed in the error popup by adding indentation to each line.
|
@@ -127,12 +169,14 @@ class _ErrorPopupUtility(QObject):
|
|
127
169
|
error_message = " ".join(captured_message)
|
128
170
|
return error_message
|
129
171
|
|
172
|
+
def get_error_message(self, exctype, value, tb):
|
173
|
+
return "".join(traceback.format_exception(exctype, value, tb))
|
174
|
+
|
130
175
|
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
131
176
|
if popup_error or self.enable_error_popup:
|
132
|
-
error_message = traceback.format_exception(exctype, value, tb)
|
133
177
|
self.error_occurred.emit(
|
134
178
|
"Method error" if popup_error else "Application Error",
|
135
|
-
|
179
|
+
self.get_error_message(exctype, value, tb),
|
136
180
|
self.parent(),
|
137
181
|
)
|
138
182
|
else:
|
bec_widgets/tests/utils.py
CHANGED
@@ -224,3 +224,11 @@ DEVICES = [
|
|
224
224
|
Positioner("test", limits=[-10, 10], read_value=2.0),
|
225
225
|
Device("test_device"),
|
226
226
|
]
|
227
|
+
|
228
|
+
|
229
|
+
def check_remote_data_size(widget, plot_name, num_elements):
|
230
|
+
"""
|
231
|
+
Check if the remote data has the correct number of elements.
|
232
|
+
Used in the qtbot.waitUntil function.
|
233
|
+
"""
|
234
|
+
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, field_validator
|
|
12
12
|
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
13
13
|
from qtpy.QtWidgets import QApplication
|
14
14
|
|
15
|
-
from bec_widgets.cli.rpc_register import RPCRegister
|
15
|
+
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
16
16
|
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
17
17
|
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
18
18
|
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
bec_widgets/utils/widget_io.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
# pylint: disable=no-name-in-module
|
2
2
|
from abc import ABC, abstractmethod
|
3
|
-
from typing import Literal
|
4
3
|
|
5
4
|
from qtpy.QtWidgets import (
|
6
5
|
QApplication,
|
@@ -28,6 +27,15 @@ class WidgetHandler(ABC):
|
|
28
27
|
def set_value(self, widget: QWidget, value):
|
29
28
|
"""Set a value on the widget instance."""
|
30
29
|
|
30
|
+
def connect_change_signal(self, widget: QWidget, slot):
|
31
|
+
"""
|
32
|
+
Connect a change signal from this widget to the given slot.
|
33
|
+
If the widget type doesn't have a known "value changed" signal, do nothing.
|
34
|
+
|
35
|
+
slot: a function accepting two arguments (widget, value)
|
36
|
+
"""
|
37
|
+
pass
|
38
|
+
|
31
39
|
|
32
40
|
class LineEditHandler(WidgetHandler):
|
33
41
|
"""Handler for QLineEdit widgets."""
|
@@ -38,6 +46,9 @@ class LineEditHandler(WidgetHandler):
|
|
38
46
|
def set_value(self, widget: QLineEdit, value: str) -> None:
|
39
47
|
widget.setText(value)
|
40
48
|
|
49
|
+
def connect_change_signal(self, widget: QLineEdit, slot):
|
50
|
+
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
|
51
|
+
|
41
52
|
|
42
53
|
class ComboBoxHandler(WidgetHandler):
|
43
54
|
"""Handler for QComboBox widgets."""
|
@@ -53,6 +64,11 @@ class ComboBoxHandler(WidgetHandler):
|
|
53
64
|
if isinstance(value, int):
|
54
65
|
widget.setCurrentIndex(value)
|
55
66
|
|
67
|
+
def connect_change_signal(self, widget: QComboBox, slot):
|
68
|
+
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
|
69
|
+
# We use currentIndexChanged(int) for a consistent behavior.
|
70
|
+
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
71
|
+
|
56
72
|
|
57
73
|
class TableWidgetHandler(WidgetHandler):
|
58
74
|
"""Handler for QTableWidget widgets."""
|
@@ -72,6 +88,16 @@ class TableWidgetHandler(WidgetHandler):
|
|
72
88
|
item = QTableWidgetItem(str(cell_value))
|
73
89
|
widget.setItem(row, col, item)
|
74
90
|
|
91
|
+
def connect_change_signal(self, widget: QTableWidget, slot):
|
92
|
+
# If desired, we could connect cellChanged(row, col) and then fetch all data.
|
93
|
+
# This might be noisy if table is large.
|
94
|
+
# For demonstration, connect cellChanged to update entire table value.
|
95
|
+
def on_cell_changed(row, col, w=widget):
|
96
|
+
val = self.get_value(w)
|
97
|
+
slot(w, val)
|
98
|
+
|
99
|
+
widget.cellChanged.connect(on_cell_changed)
|
100
|
+
|
75
101
|
|
76
102
|
class SpinBoxHandler(WidgetHandler):
|
77
103
|
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
@@ -82,6 +108,9 @@ class SpinBoxHandler(WidgetHandler):
|
|
82
108
|
def set_value(self, widget, value):
|
83
109
|
widget.setValue(value)
|
84
110
|
|
111
|
+
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
|
112
|
+
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
|
113
|
+
|
85
114
|
|
86
115
|
class CheckBoxHandler(WidgetHandler):
|
87
116
|
"""Handler for QCheckBox widgets."""
|
@@ -92,6 +121,9 @@ class CheckBoxHandler(WidgetHandler):
|
|
92
121
|
def set_value(self, widget, value):
|
93
122
|
widget.setChecked(value)
|
94
123
|
|
124
|
+
def connect_change_signal(self, widget: QCheckBox, slot):
|
125
|
+
widget.toggled.connect(lambda val, w=widget: slot(w, val))
|
126
|
+
|
95
127
|
|
96
128
|
class LabelHandler(WidgetHandler):
|
97
129
|
"""Handler for QLabel widgets."""
|
@@ -99,12 +131,15 @@ class LabelHandler(WidgetHandler):
|
|
99
131
|
def get_value(self, widget, **kwargs):
|
100
132
|
return widget.text()
|
101
133
|
|
102
|
-
def set_value(self, widget, value):
|
134
|
+
def set_value(self, widget: QLabel, value):
|
103
135
|
widget.setText(value)
|
104
136
|
|
137
|
+
# QLabel typically doesn't have user-editable changes. No signal to connect.
|
138
|
+
# If needed, this can remain empty.
|
139
|
+
|
105
140
|
|
106
141
|
class WidgetIO:
|
107
|
-
"""Public interface for getting
|
142
|
+
"""Public interface for getting, setting values and connecting signals using handler mapping"""
|
108
143
|
|
109
144
|
_handlers = {
|
110
145
|
QLineEdit: LineEditHandler,
|
@@ -148,6 +183,17 @@ class WidgetIO:
|
|
148
183
|
elif not ignore_errors:
|
149
184
|
raise ValueError(f"No handler for widget type: {type(widget)}")
|
150
185
|
|
186
|
+
@staticmethod
|
187
|
+
def connect_widget_change_signal(widget, slot):
|
188
|
+
"""
|
189
|
+
Connect the widget's value-changed signal to a generic slot function (widget, value).
|
190
|
+
This now delegates the logic to the widget's handler.
|
191
|
+
"""
|
192
|
+
handler_class = WidgetIO._find_handler(widget)
|
193
|
+
if handler_class:
|
194
|
+
handler = handler_class()
|
195
|
+
handler.connect_change_signal(widget, slot)
|
196
|
+
|
151
197
|
@staticmethod
|
152
198
|
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
153
199
|
"""
|
@@ -309,8 +355,8 @@ class WidgetHierarchy:
|
|
309
355
|
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
310
356
|
|
311
357
|
|
312
|
-
# Example
|
313
|
-
|
358
|
+
# Example usage
|
359
|
+
def hierarchy_example(): # pragma: no cover
|
314
360
|
app = QApplication([])
|
315
361
|
|
316
362
|
# Create instance of WidgetHierarchy
|
@@ -365,3 +411,37 @@ if __name__ == "__main__": # pragma: no cover
|
|
365
411
|
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
366
412
|
|
367
413
|
app.exec()
|
414
|
+
|
415
|
+
|
416
|
+
def widget_io_signal_example(): # pragma: no cover
|
417
|
+
app = QApplication([])
|
418
|
+
|
419
|
+
main_widget = QWidget()
|
420
|
+
layout = QVBoxLayout(main_widget)
|
421
|
+
line_edit = QLineEdit(main_widget)
|
422
|
+
combo_box = QComboBox(main_widget)
|
423
|
+
spin_box = QSpinBox(main_widget)
|
424
|
+
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
425
|
+
|
426
|
+
layout.addWidget(line_edit)
|
427
|
+
layout.addWidget(combo_box)
|
428
|
+
layout.addWidget(spin_box)
|
429
|
+
|
430
|
+
main_widget.show()
|
431
|
+
|
432
|
+
def universal_slot(w, val):
|
433
|
+
print(f"Widget {w.objectName() or w} changed, new value: {val}")
|
434
|
+
|
435
|
+
# Connect all supported widgets through their handlers
|
436
|
+
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
437
|
+
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
438
|
+
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
439
|
+
|
440
|
+
app.exec_()
|
441
|
+
|
442
|
+
|
443
|
+
if __name__ == "__main__": # pragma: no cover
|
444
|
+
# Change example function to test different scenarios
|
445
|
+
|
446
|
+
# hierarchy_example()
|
447
|
+
widget_io_signal_example()
|
@@ -6,7 +6,7 @@ from pydantic import Field
|
|
6
6
|
from pyqtgraph.dockarea import Dock, DockLabel
|
7
7
|
from qtpy import QtCore, QtGui
|
8
8
|
|
9
|
-
from bec_widgets.cli.
|
9
|
+
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
10
10
|
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
11
11
|
from bec_widgets.utils.bec_widget import BECWidget
|
12
12
|
|