bec-widgets 2.3.0__py3-none-any.whl → 2.5.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.
- .github/ISSUE_TEMPLATE/bug_report.md +26 -0
- .github/ISSUE_TEMPLATE/feature_request.md +48 -0
- .github/workflows/check_pr.yml +28 -0
- .github/workflows/ci.yml +36 -0
- .github/workflows/end2end-conda.yml +48 -0
- .github/workflows/formatter.yml +61 -0
- .github/workflows/generate-cli-check.yml +49 -0
- .github/workflows/pytest-matrix.yml +49 -0
- .github/workflows/pytest.yml +65 -0
- .github/workflows/semantic_release.yml +103 -0
- CHANGELOG.md +1726 -1546
- LICENSE +1 -1
- PKG-INFO +2 -1
- README.md +11 -0
- bec_widgets/cli/client.py +346 -0
- bec_widgets/examples/jupyter_console/jupyter_console_window.py +8 -8
- bec_widgets/tests/utils.py +3 -3
- bec_widgets/utils/entry_validator.py +13 -3
- bec_widgets/utils/side_panel.py +65 -39
- bec_widgets/utils/toolbar.py +79 -0
- bec_widgets/widgets/containers/layout_manager/layout_manager.py +34 -1
- bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +1 -1
- bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +27 -31
- bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +1 -1
- bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +1 -1
- bec_widgets/widgets/editors/dict_backed_table.py +7 -0
- bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +1 -0
- bec_widgets/widgets/editors/web_console/register_web_console.py +15 -0
- bec_widgets/widgets/editors/web_console/web_console.py +230 -0
- bec_widgets/widgets/editors/web_console/web_console.pyproject +1 -0
- bec_widgets/widgets/editors/web_console/web_console_plugin.py +54 -0
- bec_widgets/widgets/plots/image/image.py +90 -0
- bec_widgets/widgets/plots/roi/__init__.py +0 -0
- bec_widgets/widgets/plots/roi/image_roi.py +867 -0
- bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +11 -46
- bec_widgets/widgets/utility/visual/color_button_native/__init__.py +0 -0
- bec_widgets/widgets/utility/visual/color_button_native/color_button_native.py +58 -0
- bec_widgets/widgets/utility/visual/color_button_native/color_button_native.pyproject +1 -0
- bec_widgets/widgets/utility/visual/color_button_native/color_button_native_plugin.py +56 -0
- bec_widgets/widgets/utility/visual/color_button_native/register_color_button_native.py +17 -0
- {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/METADATA +2 -1
- {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/RECORD +46 -25
- {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/licenses/LICENSE +1 -1
- pyproject.toml +17 -5
- {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/WHEEL +0 -0
- {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,230 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import secrets
|
4
|
+
import subprocess
|
5
|
+
import time
|
6
|
+
|
7
|
+
from bec_lib.logger import bec_logger
|
8
|
+
from louie.saferef import safe_ref
|
9
|
+
from qtpy.QtCore import QUrl, qInstallMessageHandler
|
10
|
+
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
11
|
+
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
12
|
+
|
13
|
+
from bec_widgets.utils.bec_widget import BECWidget
|
14
|
+
|
15
|
+
logger = bec_logger.logger
|
16
|
+
|
17
|
+
|
18
|
+
class WebConsoleRegistry:
|
19
|
+
"""
|
20
|
+
A registry for the WebConsole class to manage its instances.
|
21
|
+
"""
|
22
|
+
|
23
|
+
def __init__(self):
|
24
|
+
"""
|
25
|
+
Initialize the registry.
|
26
|
+
"""
|
27
|
+
self._instances = {}
|
28
|
+
self._server_process = None
|
29
|
+
self._server_port = None
|
30
|
+
self._token = secrets.token_hex(16)
|
31
|
+
|
32
|
+
def register(self, instance: WebConsole):
|
33
|
+
"""
|
34
|
+
Register an instance of WebConsole.
|
35
|
+
"""
|
36
|
+
self._instances[instance.gui_id] = safe_ref(instance)
|
37
|
+
self.cleanup()
|
38
|
+
|
39
|
+
if self._server_process is None:
|
40
|
+
# Start the ttyd server if not already running
|
41
|
+
self.start_ttyd()
|
42
|
+
|
43
|
+
def start_ttyd(self, use_zsh: bool | None = None):
|
44
|
+
"""
|
45
|
+
Start the ttyd server
|
46
|
+
ttyd -q -W -t 'theme={"background": "black"}' zsh
|
47
|
+
|
48
|
+
Args:
|
49
|
+
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
|
50
|
+
"""
|
51
|
+
|
52
|
+
# First, check if ttyd is installed
|
53
|
+
try:
|
54
|
+
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
|
55
|
+
except FileNotFoundError:
|
56
|
+
# pylint: disable=raise-missing-from
|
57
|
+
raise RuntimeError("ttyd is not installed. Please install it first.")
|
58
|
+
|
59
|
+
if use_zsh is None:
|
60
|
+
# Check if we can use zsh
|
61
|
+
try:
|
62
|
+
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
|
63
|
+
use_zsh = True
|
64
|
+
except FileNotFoundError:
|
65
|
+
use_zsh = False
|
66
|
+
|
67
|
+
command = [
|
68
|
+
"ttyd",
|
69
|
+
"-p",
|
70
|
+
"0",
|
71
|
+
"-W",
|
72
|
+
"-t",
|
73
|
+
'theme={"background": "black"}',
|
74
|
+
"-c",
|
75
|
+
f"user:{self._token}",
|
76
|
+
]
|
77
|
+
if use_zsh:
|
78
|
+
command.append("zsh")
|
79
|
+
else:
|
80
|
+
command.append("bash")
|
81
|
+
|
82
|
+
# Start the ttyd server
|
83
|
+
self._server_process = subprocess.Popen(
|
84
|
+
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
85
|
+
)
|
86
|
+
|
87
|
+
self._wait_for_server_port()
|
88
|
+
|
89
|
+
self._server_process.stdout.close()
|
90
|
+
self._server_process.stderr.close()
|
91
|
+
|
92
|
+
def _wait_for_server_port(self, timeout: float = 10):
|
93
|
+
"""
|
94
|
+
Wait for the ttyd server to start and get the port number.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
timeout (float): The timeout in seconds to wait for the server to start.
|
98
|
+
"""
|
99
|
+
start_time = time.time()
|
100
|
+
while True:
|
101
|
+
output = self._server_process.stderr.readline()
|
102
|
+
if output == b"" and self._server_process.poll() is not None:
|
103
|
+
break
|
104
|
+
if not output:
|
105
|
+
continue
|
106
|
+
|
107
|
+
output = output.decode("utf-8").strip()
|
108
|
+
if "Listening on" in output:
|
109
|
+
# Extract the port number from the output
|
110
|
+
self._server_port = int(output.split(":")[-1])
|
111
|
+
logger.info(f"ttyd server started on port {self._server_port}")
|
112
|
+
break
|
113
|
+
if time.time() - start_time > timeout:
|
114
|
+
raise TimeoutError(
|
115
|
+
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
|
116
|
+
)
|
117
|
+
|
118
|
+
def cleanup(self):
|
119
|
+
"""
|
120
|
+
Clean up the registry by removing any instances that are no longer valid.
|
121
|
+
"""
|
122
|
+
for gui_id, weak_ref in list(self._instances.items()):
|
123
|
+
if weak_ref() is None:
|
124
|
+
del self._instances[gui_id]
|
125
|
+
|
126
|
+
if not self._instances and self._server_process:
|
127
|
+
# If no instances are left, terminate the server process
|
128
|
+
self._server_process.terminate()
|
129
|
+
self._server_process = None
|
130
|
+
self._server_port = None
|
131
|
+
logger.info("ttyd server terminated")
|
132
|
+
|
133
|
+
def unregister(self, instance: WebConsole):
|
134
|
+
"""
|
135
|
+
Unregister an instance of WebConsole.
|
136
|
+
|
137
|
+
Args:
|
138
|
+
instance (WebConsole): The instance to unregister.
|
139
|
+
"""
|
140
|
+
if instance.gui_id in self._instances:
|
141
|
+
del self._instances[instance.gui_id]
|
142
|
+
|
143
|
+
self.cleanup()
|
144
|
+
|
145
|
+
|
146
|
+
_web_console_registry = WebConsoleRegistry()
|
147
|
+
|
148
|
+
|
149
|
+
def suppress_qt_messages(type_, context, msg):
|
150
|
+
if context.category in ["js", "default"]:
|
151
|
+
return
|
152
|
+
print(msg)
|
153
|
+
|
154
|
+
|
155
|
+
qInstallMessageHandler(suppress_qt_messages)
|
156
|
+
|
157
|
+
|
158
|
+
class BECWebEnginePage(QWebEnginePage):
|
159
|
+
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
|
160
|
+
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
|
161
|
+
|
162
|
+
|
163
|
+
class WebConsole(BECWidget, QWidget):
|
164
|
+
"""
|
165
|
+
A simple widget to display a website
|
166
|
+
"""
|
167
|
+
|
168
|
+
PLUGIN = True
|
169
|
+
ICON_NAME = "terminal"
|
170
|
+
|
171
|
+
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
172
|
+
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
173
|
+
_web_console_registry.register(self)
|
174
|
+
self._token = _web_console_registry._token
|
175
|
+
layout = QVBoxLayout()
|
176
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
177
|
+
self.browser = QWebEngineView(self)
|
178
|
+
self.page = BECWebEnginePage(self)
|
179
|
+
self.page.authenticationRequired.connect(self._authenticate)
|
180
|
+
self.browser.setPage(self.page)
|
181
|
+
layout.addWidget(self.browser)
|
182
|
+
self.setLayout(layout)
|
183
|
+
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
184
|
+
|
185
|
+
def write(self, data: str, send_return: bool = True):
|
186
|
+
"""
|
187
|
+
Send data to the web page
|
188
|
+
"""
|
189
|
+
self.page.runJavaScript(f"window.term.paste('{data}');")
|
190
|
+
if send_return:
|
191
|
+
self.send_return()
|
192
|
+
|
193
|
+
def _authenticate(self, _, auth):
|
194
|
+
"""
|
195
|
+
Authenticate the request with the provided username and password.
|
196
|
+
"""
|
197
|
+
auth.setUser("user")
|
198
|
+
auth.setPassword(self._token)
|
199
|
+
|
200
|
+
def send_return(self):
|
201
|
+
"""
|
202
|
+
Send return to the web page
|
203
|
+
"""
|
204
|
+
self.page.runJavaScript(
|
205
|
+
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
|
206
|
+
)
|
207
|
+
|
208
|
+
def send_ctrl_c(self):
|
209
|
+
"""
|
210
|
+
Send Ctrl+C to the web page
|
211
|
+
"""
|
212
|
+
self.page.runJavaScript(
|
213
|
+
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
214
|
+
)
|
215
|
+
|
216
|
+
def cleanup(self):
|
217
|
+
"""
|
218
|
+
Clean up the registry by removing any instances that are no longer valid.
|
219
|
+
"""
|
220
|
+
_web_console_registry.unregister(self)
|
221
|
+
super().cleanup()
|
222
|
+
|
223
|
+
|
224
|
+
if __name__ == "__main__": # pragma: no cover
|
225
|
+
import sys
|
226
|
+
|
227
|
+
app = QApplication(sys.argv)
|
228
|
+
widget = WebConsole()
|
229
|
+
widget.show()
|
230
|
+
sys.exit(app.exec_())
|
@@ -0,0 +1 @@
|
|
1
|
+
{'files': ['web_console.py']}
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# Copyright (C) 2022 The Qt Company Ltd.
|
2
|
+
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
3
|
+
|
4
|
+
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
5
|
+
|
6
|
+
from bec_widgets.utils.bec_designer import designer_material_icon
|
7
|
+
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
8
|
+
|
9
|
+
DOM_XML = """
|
10
|
+
<ui language='c++'>
|
11
|
+
<widget class='WebConsole' name='web_console'>
|
12
|
+
</widget>
|
13
|
+
</ui>
|
14
|
+
"""
|
15
|
+
|
16
|
+
|
17
|
+
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
18
|
+
def __init__(self):
|
19
|
+
super().__init__()
|
20
|
+
self._form_editor = None
|
21
|
+
|
22
|
+
def createWidget(self, parent):
|
23
|
+
t = WebConsole(parent)
|
24
|
+
return t
|
25
|
+
|
26
|
+
def domXml(self):
|
27
|
+
return DOM_XML
|
28
|
+
|
29
|
+
def group(self):
|
30
|
+
return "BEC Console"
|
31
|
+
|
32
|
+
def icon(self):
|
33
|
+
return designer_material_icon(WebConsole.ICON_NAME)
|
34
|
+
|
35
|
+
def includeFile(self):
|
36
|
+
return "web_console"
|
37
|
+
|
38
|
+
def initialize(self, form_editor):
|
39
|
+
self._form_editor = form_editor
|
40
|
+
|
41
|
+
def isContainer(self):
|
42
|
+
return False
|
43
|
+
|
44
|
+
def isInitialized(self):
|
45
|
+
return self._form_editor is not None
|
46
|
+
|
47
|
+
def name(self):
|
48
|
+
return "WebConsole"
|
49
|
+
|
50
|
+
def toolTip(self):
|
51
|
+
return ""
|
52
|
+
|
53
|
+
def whatsThis(self):
|
54
|
+
return self.toolTip()
|
@@ -20,6 +20,12 @@ from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
|
20
20
|
)
|
21
21
|
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
|
22
22
|
from bec_widgets.widgets.plots.plot_base import PlotBase
|
23
|
+
from bec_widgets.widgets.plots.roi.image_roi import (
|
24
|
+
BaseROI,
|
25
|
+
CircularROI,
|
26
|
+
RectangularROI,
|
27
|
+
ROIController,
|
28
|
+
)
|
23
29
|
|
24
30
|
logger = bec_logger.logger
|
25
31
|
|
@@ -111,6 +117,9 @@ class Image(PlotBase):
|
|
111
117
|
"transpose.setter",
|
112
118
|
"image",
|
113
119
|
"main_image",
|
120
|
+
"add_roi",
|
121
|
+
"remove_roi",
|
122
|
+
"rois",
|
114
123
|
]
|
115
124
|
sync_colorbar_with_autorange = Signal()
|
116
125
|
|
@@ -128,6 +137,7 @@ class Image(PlotBase):
|
|
128
137
|
self.gui_id = config.gui_id
|
129
138
|
self._color_bar = None
|
130
139
|
self._main_image = ImageItem()
|
140
|
+
self.roi_controller = ROIController(colormap="viridis")
|
131
141
|
super().__init__(
|
132
142
|
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
133
143
|
)
|
@@ -139,6 +149,9 @@ class Image(PlotBase):
|
|
139
149
|
# Default Color map to plasma
|
140
150
|
self.color_map = "plasma"
|
141
151
|
|
152
|
+
# Headless controller keeps the canonical list.
|
153
|
+
self._roi_manager_dialog = None
|
154
|
+
|
142
155
|
################################################################################
|
143
156
|
# Widget Specific GUI interactions
|
144
157
|
################################################################################
|
@@ -304,9 +317,81 @@ class Image(PlotBase):
|
|
304
317
|
if vrange: # should be at the end to disable the autorange if defined
|
305
318
|
self.v_range = vrange
|
306
319
|
|
320
|
+
################################################################################
|
321
|
+
# Static rois with roi manager
|
322
|
+
|
323
|
+
def add_roi(
|
324
|
+
self,
|
325
|
+
kind: Literal["rect", "circle"] = "rect",
|
326
|
+
name: str | None = None,
|
327
|
+
line_width: int | None = 10,
|
328
|
+
pos: tuple[float, float] | None = (10, 10),
|
329
|
+
size: tuple[float, float] | None = (50, 50),
|
330
|
+
**pg_kwargs,
|
331
|
+
) -> RectangularROI | CircularROI:
|
332
|
+
"""
|
333
|
+
Add a ROI to the image.
|
334
|
+
|
335
|
+
Args:
|
336
|
+
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
337
|
+
name(str): The name of the ROI.
|
338
|
+
line_width(int): The line width of the ROI.
|
339
|
+
pos(tuple): The position of the ROI.
|
340
|
+
size(tuple): The size of the ROI.
|
341
|
+
**pg_kwargs: Additional arguments for the ROI.
|
342
|
+
|
343
|
+
Returns:
|
344
|
+
RectangularROI | CircularROI: The created ROI object.
|
345
|
+
"""
|
346
|
+
if name is None:
|
347
|
+
name = f"ROI_{len(self.roi_controller.rois) + 1}"
|
348
|
+
if kind == "rect":
|
349
|
+
roi = RectangularROI(
|
350
|
+
pos=pos,
|
351
|
+
size=size,
|
352
|
+
parent_image=self,
|
353
|
+
line_width=line_width,
|
354
|
+
label=name,
|
355
|
+
**pg_kwargs,
|
356
|
+
)
|
357
|
+
elif kind == "circle":
|
358
|
+
roi = CircularROI(
|
359
|
+
pos=pos,
|
360
|
+
size=size,
|
361
|
+
parent_image=self,
|
362
|
+
line_width=line_width,
|
363
|
+
label=name,
|
364
|
+
**pg_kwargs,
|
365
|
+
)
|
366
|
+
else:
|
367
|
+
raise ValueError("kind must be 'rect' or 'circle'")
|
368
|
+
|
369
|
+
# Add to plot and controller (controller assigns color)
|
370
|
+
self.plot_item.addItem(roi)
|
371
|
+
self.roi_controller.add_roi(roi)
|
372
|
+
return roi
|
373
|
+
|
374
|
+
def remove_roi(self, roi: int | str):
|
375
|
+
"""Remove an ROI by index or label via the ROIController."""
|
376
|
+
if isinstance(roi, int):
|
377
|
+
self.roi_controller.remove_roi_by_index(roi)
|
378
|
+
elif isinstance(roi, str):
|
379
|
+
self.roi_controller.remove_roi_by_name(roi)
|
380
|
+
else:
|
381
|
+
raise ValueError("roi must be an int index or str name")
|
382
|
+
|
307
383
|
################################################################################
|
308
384
|
# Widget Specific Properties
|
309
385
|
################################################################################
|
386
|
+
################################################################################
|
387
|
+
# Rois
|
388
|
+
|
389
|
+
@property
|
390
|
+
def rois(self) -> list[BaseROI]:
|
391
|
+
"""
|
392
|
+
Get the list of ROIs.
|
393
|
+
"""
|
394
|
+
return self.roi_controller.rois
|
310
395
|
|
311
396
|
################################################################################
|
312
397
|
# Colorbar toggle
|
@@ -925,6 +1010,11 @@ class Image(PlotBase):
|
|
925
1010
|
"""
|
926
1011
|
Disconnect the image update signals and clean up the image.
|
927
1012
|
"""
|
1013
|
+
# Remove all ROIs
|
1014
|
+
rois = self.rois
|
1015
|
+
for roi in rois:
|
1016
|
+
roi.remove()
|
1017
|
+
|
928
1018
|
# Main Image cleanup
|
929
1019
|
if self._main_image.config.monitor is not None:
|
930
1020
|
self.disconnect_monitor(self._main_image.config.monitor)
|
File without changes
|