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.
Files changed (46) hide show
  1. .github/ISSUE_TEMPLATE/bug_report.md +26 -0
  2. .github/ISSUE_TEMPLATE/feature_request.md +48 -0
  3. .github/workflows/check_pr.yml +28 -0
  4. .github/workflows/ci.yml +36 -0
  5. .github/workflows/end2end-conda.yml +48 -0
  6. .github/workflows/formatter.yml +61 -0
  7. .github/workflows/generate-cli-check.yml +49 -0
  8. .github/workflows/pytest-matrix.yml +49 -0
  9. .github/workflows/pytest.yml +65 -0
  10. .github/workflows/semantic_release.yml +103 -0
  11. CHANGELOG.md +1726 -1546
  12. LICENSE +1 -1
  13. PKG-INFO +2 -1
  14. README.md +11 -0
  15. bec_widgets/cli/client.py +346 -0
  16. bec_widgets/examples/jupyter_console/jupyter_console_window.py +8 -8
  17. bec_widgets/tests/utils.py +3 -3
  18. bec_widgets/utils/entry_validator.py +13 -3
  19. bec_widgets/utils/side_panel.py +65 -39
  20. bec_widgets/utils/toolbar.py +79 -0
  21. bec_widgets/widgets/containers/layout_manager/layout_manager.py +34 -1
  22. bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +1 -1
  23. bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +27 -31
  24. bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +1 -1
  25. bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +1 -1
  26. bec_widgets/widgets/editors/dict_backed_table.py +7 -0
  27. bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +1 -0
  28. bec_widgets/widgets/editors/web_console/register_web_console.py +15 -0
  29. bec_widgets/widgets/editors/web_console/web_console.py +230 -0
  30. bec_widgets/widgets/editors/web_console/web_console.pyproject +1 -0
  31. bec_widgets/widgets/editors/web_console/web_console_plugin.py +54 -0
  32. bec_widgets/widgets/plots/image/image.py +90 -0
  33. bec_widgets/widgets/plots/roi/__init__.py +0 -0
  34. bec_widgets/widgets/plots/roi/image_roi.py +867 -0
  35. bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +11 -46
  36. bec_widgets/widgets/utility/visual/color_button_native/__init__.py +0 -0
  37. bec_widgets/widgets/utility/visual/color_button_native/color_button_native.py +58 -0
  38. bec_widgets/widgets/utility/visual/color_button_native/color_button_native.pyproject +1 -0
  39. bec_widgets/widgets/utility/visual/color_button_native/color_button_native_plugin.py +56 -0
  40. bec_widgets/widgets/utility/visual/color_button_native/register_color_button_native.py +17 -0
  41. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/METADATA +2 -1
  42. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/RECORD +46 -25
  43. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/licenses/LICENSE +1 -1
  44. pyproject.toml +17 -5
  45. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/WHEEL +0 -0
  46. {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