mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-13 12:10:54 +02:00
Compare commits
89 Commits
feat/devel
...
prototype/
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f3742da9d | |||
| 6072b8598a | |||
| feb94f6ce6 | |||
| 1fcc4cd283 | |||
| f7e5d0fe7b | |||
| df4609b79e | |||
| c56f91809a | |||
| 2f8d83052f | |||
| c32df2baee | |||
| bcbe722d1d | |||
| 275581a0fe | |||
| 59bfc5826f | |||
| 3d446e23e1 | |||
| a98e04a652 | |||
| 2c66385515 | |||
| b67025c6a2 | |||
| b9bbf5d6e1 | |||
| 4b2bfd6252 | |||
| e681f3ce06 | |||
| 7b5e58a5b8 | |||
| c83338e15d | |||
| e7b1c6ab20 | |||
| 4c78946da6 | |||
| 4f3acba215 | |||
| f48470f4cd | |||
| 89db2f5c98 | |||
| 10c9da8898 | |||
| cc05f5973e | |||
| 641efd0990 | |||
| d6178b4338 | |||
| 0c3cb9fd75 | |||
| bef06ab35f | |||
| 5d5009d4ab | |||
| 12592cb544 | |||
| 7ecb257897 | |||
| b200226e07 | |||
| ecbefb69e1 | |||
| 127199c56a | |||
| 2efb06b6a2 | |||
| 6af2b09f19 | |||
| 48b854a9ab | |||
| cee7998122 | |||
| 6ecc06de1d | |||
| 3bdb8f2559 | |||
| e3c7e3ff44 | |||
| af20396968 | |||
| 2a4a11a96f | |||
| eaf1634ca5 | |||
| 0e356e0ce9 | |||
| 4e172a8edd | |||
| 6e4b669b3a | |||
| a3dc5091e3 | |||
| d69220c6dd | |||
| 353c82c868 | |||
| ab787fc4a8 | |||
| 9c40e31ae5 | |||
| a84459acbf | |||
| 0dce0a0f4f | |||
| 7421166bee | |||
| 1d5d83c7ef | |||
| 38a4f3ad9a | |||
| 4889f01ef3 | |||
| 652ec81d01 | |||
| 391e2f7ef4 | |||
| 9bd1efafde | |||
| e01518898e | |||
| eb5d56a388 | |||
| 09d00c4f11 | |||
| 19e8e5a891 | |||
| cea2e68fbd | |||
| 6932a5e2dd | |||
| ab5a78e2fd | |||
| 022b10ff7a | |||
| 062042c35c | |||
| 0756ebb389 | |||
| 166b56b560 | |||
| 7884aec801 | |||
| e7f9919620 | |||
| 25f9b09027 | |||
| 4495cc77db | |||
| 2eb04e0ffa | |||
| 0a4d3b5818 | |||
| d7a946e432 | |||
| 02887d2d9a | |||
| bc8a3282db | |||
| bfc72e86e8 | |||
| c20a1ac865 | |||
| df9b5b7588 | |||
| b092d2a094 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,51 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.39.0 (2025-09-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc**: Fix hide/show
|
||||
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
|
||||
|
||||
### Features
|
||||
|
||||
- **rpc_base**: Windows can be raised to front from CLI
|
||||
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
|
||||
|
||||
|
||||
## v2.38.4 (2025-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Add support for specifying preview signals through cli
|
||||
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
|
||||
|
||||
|
||||
## v2.38.3 (2025-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **connector**: Only flush pending events
|
||||
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
|
||||
|
||||
- **ringprogressbar**: Fix client signature
|
||||
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
|
||||
|
||||
- **ringprogressbar**: Various fixes and improvements
|
||||
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
|
||||
|
||||
### Chores
|
||||
|
||||
- Deprecate 3.10, add 3.13
|
||||
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
|
||||
|
||||
### Testing
|
||||
|
||||
- **ringprogressbar**: Extend e2e test
|
||||
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
|
||||
|
||||
|
||||
## v2.38.2 (2025-09-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from bec_qthemes.qss_editor.qss_editor import ThemeWidget
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.examples.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
@@ -32,8 +31,8 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(0, 0, 6, 0)
|
||||
layout.setSpacing(6)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
@@ -48,25 +47,25 @@ class BECMainApp(BECMainWindow):
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self)
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
self.device_manager_view = DeviceManagerView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="Developer View",
|
||||
id="developer_view",
|
||||
widget=self.developer_view,
|
||||
mini_text="Dev",
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
widget=self.device_manager_view,
|
||||
mini_text="Devices",
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
@@ -151,8 +150,6 @@ class BECMainApp(BECMainWindow):
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
@@ -206,21 +203,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
w.show()
|
||||
# theme_widget = ThemeWidget()
|
||||
# theme_widget.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -31,7 +31,7 @@ class SideBar(QScrollArea):
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
collapsed_width: int = 65,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -11,7 +9,6 @@ from qtpy.QtWidgets import (
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -23,42 +20,6 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
@@ -115,68 +76,6 @@ class ViewBase(QWidget):
|
||||
"""
|
||||
return True
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
|
||||
@@ -27,6 +27,7 @@ class _WidgetsEnumType(str, enum.Enum):
|
||||
|
||||
|
||||
_Widgets = {
|
||||
"AbortButton": "AbortButton",
|
||||
"BECDockArea": "BECDockArea",
|
||||
"BECMainWindow": "BECMainWindow",
|
||||
"BECProgressBar": "BECProgressBar",
|
||||
@@ -49,6 +50,7 @@ _Widgets = {
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
"PositionerControlLine": "PositionerControlLine",
|
||||
"PositionerGroup": "PositionerGroup",
|
||||
"ResetButton": "ResetButton",
|
||||
"ResumeButton": "ResumeButton",
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"SBBMonitor": "SBBMonitor",
|
||||
@@ -58,6 +60,7 @@ _Widgets = {
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"StopButton": "StopButton",
|
||||
"TextBox": "TextBox",
|
||||
"VSCodeEditor": "VSCodeEditor",
|
||||
"Waveform": "Waveform",
|
||||
@@ -94,6 +97,28 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AbortButton(RPCBase):
|
||||
"""A button that abort the scan."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class AdvancedDockArea(RPCBase):
|
||||
@rpc_call
|
||||
def new(
|
||||
@@ -211,26 +236,6 @@ class AutoUpdates(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class AvailableDeviceResources(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class BECDock(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
@@ -1095,48 +1100,6 @@ class Curve(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DMConfigView(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DMOphydTest(RPCBase):
|
||||
"""Widget to test device configurations using ophyd devices."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
|
||||
@@ -2451,7 +2414,7 @@ class Image(RPCBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
@@ -4027,6 +3990,28 @@ class RectangularROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ResetButton(RPCBase):
|
||||
"""A button that resets the scan queue."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class ResumeButton(RPCBase):
|
||||
"""A button that continue scan queue."""
|
||||
|
||||
@@ -4213,7 +4198,7 @@ class RingProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_precision(self, precision: "int", bar_index: "int | None" = None):
|
||||
def set_precision(self, precision: "int", bar_index: "int" = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
@@ -5002,6 +4987,28 @@ class SignalLineEdit(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class StopButton(RPCBase):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class TextBox(RPCBase):
|
||||
"""A widget that displays text in plain and HTML format"""
|
||||
|
||||
|
||||
@@ -285,18 +285,6 @@ class BECGuiClient(RPCBase):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def raise_window(self, wait: bool = True) -> None:
|
||||
"""
|
||||
Bring GUI windows to the front.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
@@ -455,8 +443,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
@@ -466,24 +454,11 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
|
||||
@@ -202,11 +202,6 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def raise_window(self):
|
||||
"""Bring this widget (or its container) to the front."""
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
@@ -230,12 +225,6 @@ class RPCBase:
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
gui_id = obj.get("container_proxy") # type: ignore
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
|
||||
67
bec_widgets/examples/bec_main_app/bec_main_app.py
Normal file
67
bec_widgets/examples/bec_main_app/bec_main_app.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
|
||||
|
||||
class BECMainApp(QtWidgets.QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main layout
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Tab widget as central area
|
||||
self.tabs = QtWidgets.QTabWidget(self)
|
||||
self.tabs.setContentsMargins(0, 0, 0, 0)
|
||||
self.tabs.setTabPosition(QtWidgets.QTabWidget.West) # Tabs on the left side
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
# Add DM
|
||||
self._add_device_manager_view()
|
||||
|
||||
# Add Plot area
|
||||
self._add_ad_dockarea()
|
||||
|
||||
# Adjust size of tab bar
|
||||
# TODO not yet properly working, tabs a spread across the full length, to be checked!
|
||||
tab_bar = self.tabs.tabBar()
|
||||
tab_bar.setFixedWidth(tab_bar.sizeHint().width())
|
||||
|
||||
def _add_device_manager_view(self) -> None:
|
||||
self.device_manager_view = DeviceManagerView(parent=self)
|
||||
self.add_tab(self.device_manager_view, "Device Manager")
|
||||
|
||||
def _add_ad_dockarea(self) -> None:
|
||||
self.advanced_dock_area = AdvancedDockArea(parent=self)
|
||||
self.add_tab(self.advanced_dock_area, "Plot Area")
|
||||
|
||||
def add_tab(self, widget: QtWidgets.QWidget, title: str):
|
||||
"""Add a custom QWidget as a tab."""
|
||||
tab_container = QtWidgets.QWidget()
|
||||
tab_layout = QtWidgets.QVBoxLayout(tab_container)
|
||||
tab_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tab_layout.setSpacing(0)
|
||||
|
||||
tab_layout.addWidget(widget)
|
||||
self.tabs.addTab(tab_container, title)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
win = BECMainApp()
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
cfg = yaml_load(config_path)
|
||||
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
win.device_manager_view.device_table_view.set_device_config(cfg)
|
||||
win.resize(1920, 1080)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,29 +1,322 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typing import List
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget
|
||||
import PySide6QtAds as QtAds
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.script_executor import upload_script
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QSplitter, QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
A view for users to write scripts and macros and execute them within the application.
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class DeveloperView(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.console = WebConsole(self)
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere) # wrap everything, including code
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
|
||||
self.monaco.signature_help.connect(self.signature_help.setMarkdown)
|
||||
|
||||
# Create the dock widgets
|
||||
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.explorer_dock.setWidget(self.explorer)
|
||||
|
||||
self.console_dock = QtAds.CDockWidget("Console", self)
|
||||
self.console_dock.setWidget(self.console)
|
||||
|
||||
self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self)
|
||||
self.monaco_dock.setWidget(self.monaco)
|
||||
|
||||
self.terminal_dock = QtAds.CDockWidget("Terminal", self)
|
||||
self.terminal_dock.setWidget(self.terminal)
|
||||
|
||||
# Monaco will be central widget
|
||||
self.dock_manager.setCentralWidget(self.monaco_dock)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
area_bottom = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom)
|
||||
|
||||
area_left = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock
|
||||
)
|
||||
area_left.titleBar().setVisible(False)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
|
||||
self.plotting_ads_dock.setWidget(self.plotting_ads)
|
||||
|
||||
self.signature_dock = QtAds.CDockWidget("Signature Help", self)
|
||||
self.signature_dock.setWidget(self.signature_help)
|
||||
|
||||
area_right = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 5, 3], [7, 3])
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(icon_name="save", tooltip="Save", parent=self)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(icon_name="save_as", tooltip="Save As", parent=self)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow", tooltip="Run current file", filled=True, parent=self
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop", tooltip="Stop current execution", filled=True, parent=self
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim", tooltip="Vim", filled=True, parent=self, checkable=True
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return
|
||||
self.current_script_id = upload_script(
|
||||
self.client.connector, self.script_editor_tab.widget().get_text()
|
||||
)
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
print("Stopping execution...")
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
self._current_script_id = value
|
||||
self._update_subscription()
|
||||
|
||||
def _update_subscription(self):
|
||||
if self.current_script_id:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if not current_lines:
|
||||
self.script_editor_tab.widget().clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
self.script_editor_tab.widget().clear_highlighted_lines()
|
||||
self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
@@ -37,20 +330,6 @@ if __name__ == "__main__":
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
import re
|
||||
|
||||
import markdown
|
||||
import PySide6QtAds as QtAds
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> str:
|
||||
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
|
||||
|
||||
# Preprocess: convert consecutive >>> lines to Python code blocks
|
||||
def replace_python_examples(match):
|
||||
indent = match.group(1)
|
||||
examples = match.group(2)
|
||||
# Remove >>> prefix and clean up the code
|
||||
lines = []
|
||||
for line in examples.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(">>> "):
|
||||
lines.append(line[4:]) # Remove '>>> '
|
||||
elif line.startswith(">>>"):
|
||||
lines.append(line[3:]) # Remove '>>>'
|
||||
code = "\n".join(lines)
|
||||
|
||||
return f"{indent}```python\n{indent}{code}\n{indent}```"
|
||||
|
||||
# Match one or more consecutive >>> lines (with same indentation)
|
||||
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
|
||||
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
|
||||
|
||||
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
|
||||
html = markdown.markdown(
|
||||
md_text,
|
||||
extensions=extensions,
|
||||
extension_configs={
|
||||
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
|
||||
},
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
# Remove hardcoded background colors that conflict with themes
|
||||
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
|
||||
html = re.sub(r"background: #[^;]*;", "", html)
|
||||
|
||||
# Add CSS to force code blocks to wrap
|
||||
css = """
|
||||
<style>
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
.codehilite pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
return css + html
|
||||
|
||||
|
||||
class DeveloperWidget(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.console = WebConsole(self)
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
self.monaco.signature_help.connect(
|
||||
lambda text: self.signature_help.setHtml(markdown_to_html(text))
|
||||
)
|
||||
|
||||
# Create the dock widgets
|
||||
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.explorer_dock.setWidget(self.explorer)
|
||||
|
||||
self.console_dock = QtAds.CDockWidget("Console", self)
|
||||
self.console_dock.setWidget(self.console)
|
||||
|
||||
self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self)
|
||||
self.monaco_dock.setWidget(self.monaco)
|
||||
|
||||
self.terminal_dock = QtAds.CDockWidget("Terminal", self)
|
||||
self.terminal_dock.setWidget(self.terminal)
|
||||
|
||||
# Monaco will be central widget
|
||||
self.dock_manager.setCentralWidget(self.monaco_dock)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
area_bottom = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom)
|
||||
|
||||
area_left = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock
|
||||
)
|
||||
area_left.titleBar().setVisible(False)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
|
||||
self.plotting_ads_dock.setWidget(self.plotting_ads)
|
||||
|
||||
self.signature_dock = QtAds.CDockWidget("Signature Help", self)
|
||||
self.signature_dock.setWidget(self.signature_help)
|
||||
|
||||
area_right = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right)
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(
|
||||
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
|
||||
)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(
|
||||
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow",
|
||||
tooltip="Run current file",
|
||||
label_text="Run",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
label_text="Stop",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim",
|
||||
tooltip="Toggle Vim Mode",
|
||||
label_text="Vim",
|
||||
filled=True,
|
||||
parent=self,
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name, scope)
|
||||
|
||||
# Set read-only mode for shared files
|
||||
if "shared" in scope:
|
||||
self.monaco.set_file_readonly(file_name, True)
|
||||
|
||||
# Add appropriate icon based on file type
|
||||
if "script" in scope:
|
||||
# Use script icon for script files
|
||||
icon = material_icon("script", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
elif "macro" in scope:
|
||||
# Use function icon for macro files
|
||||
icon = material_icon("function", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return
|
||||
self.current_script_id = upload_script(
|
||||
self.client.connector, self.script_editor_tab.widget().get_text()
|
||||
)
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
print("Stopping execution...")
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
self._current_script_id = value
|
||||
self._update_subscription()
|
||||
|
||||
def _update_subscription(self):
|
||||
if self.current_script_id:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if not current_lines:
|
||||
self.script_editor_tab.widget().clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
self.script_editor_tab.widget().clear_highlighted_lines()
|
||||
self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -96,7 +96,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self.dock_manager.setStyleSheet("""""")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Available Resources Widget
|
||||
@@ -218,10 +218,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
load = MaterialIconAction(
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
icon_name="file_open", parent=self, tooltip="Load configuration file from disk"
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
@@ -229,10 +226,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Add safe to disk
|
||||
safe_to_disk = MaterialIconAction(
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
icon_name="file_save", parent=self, tooltip="Save config to disk"
|
||||
)
|
||||
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
|
||||
safe_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
@@ -240,10 +234,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from Redis",
|
||||
label_text="Reload Config",
|
||||
icon_name="cached", parent=self, tooltip="Load current config from Redis"
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
@@ -251,10 +242,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in Redis",
|
||||
label_text="Update Config",
|
||||
icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis"
|
||||
)
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
@@ -270,37 +258,27 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config",
|
||||
icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view"
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device"
|
||||
)
|
||||
add_device = MaterialIconAction(icon_name="add", parent=self, tooltip="Add new device")
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device"
|
||||
)
|
||||
remove_device = MaterialIconAction(icon_name="remove", parent=self, tooltip="Remove device")
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Rerun Validation",
|
||||
icon_name="checklist", parent=self, tooltip="Run device validation on selected devices"
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
@@ -456,13 +434,11 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
@SafeSlot()
|
||||
def _rerun_validation_action(self):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = self.device_table_view.table.selected_configs()
|
||||
self.ophyd_test_view.change_device_configs(configs, True, True)
|
||||
# Implement the logic to rerun validation on selected devices
|
||||
reply = self._coming_soon()
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(
|
||||
self, horizontal_weights: list, vertical_weights: list
|
||||
): # TODO separate logic for all ads based widgets
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
@@ -485,9 +461,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(
|
||||
self, *, horizontal=None, vertical=None
|
||||
): # TODO separate logic for all ads based widgets
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
@@ -9,7 +9,7 @@ from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -37,6 +37,9 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setStyleSheet(
|
||||
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
|
||||
)
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -85,34 +88,22 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
config.append({"name": "wrong_device", "some_value": 1})
|
||||
self.device_manager_view.device_table_view.set_device_config(config)
|
||||
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager = DeviceManagerWidget()
|
||||
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||
# device_manager.device_table_view.set_device_config(config)
|
||||
layout.addWidget(device_manager)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
dark_mode_button = DarkModeButton()
|
||||
layout.addWidget(dark_mode_button)
|
||||
widget.show()
|
||||
device_manager.show()
|
||||
device_manager.setWindowTitle("Device Manager View")
|
||||
device_manager.resize(1600, 1200)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
@@ -219,7 +219,7 @@ class BECConnector:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.sendPostedEvents()
|
||||
QApplication.processEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
|
||||
@@ -36,8 +36,6 @@ class BECWidget(BECConnector):
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
**kwargs,
|
||||
):
|
||||
@@ -67,20 +65,6 @@ class BECWidget(BECConnector):
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._loading = False
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
try:
|
||||
overlay = self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if overlay is not None:
|
||||
overlay.setGeometry(self.rect())
|
||||
overlay.raise_()
|
||||
overlay.show()
|
||||
self._loading = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Busy loader init skipped: {exc}")
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
@@ -97,77 +81,8 @@ class BECWidget(BECConnector):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
Returns the overlay instance or None if not a QWidget.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return None
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
return overlay
|
||||
|
||||
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the loading overlay.
|
||||
text(str, optional): The text to display on the overlay. If None, the text is not changed.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if getattr(self, "_busy_overlay", None) is None:
|
||||
self._ensure_busy_overlay(busy_text=text or "Loading…")
|
||||
if text is not None:
|
||||
self.set_busy_text(text)
|
||||
if enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
|
||||
def is_busy(self) -> bool:
|
||||
"""
|
||||
Check if the loading overlay is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if the loading overlay is enabled, False otherwise.
|
||||
"""
|
||||
return bool(getattr(self, "_loading", False))
|
||||
|
||||
def set_busy_text(self, text: str) -> None:
|
||||
"""
|
||||
Update the text on the loading overlay.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
overlay = self._ensure_busy_overlay(busy_text=text)
|
||||
if overlay is not None:
|
||||
overlay.set_text(text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
@@ -177,14 +92,6 @@ class BECWidget(BECConnector):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
@@ -243,22 +150,6 @@ class BECWidget(BECConnector):
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
self._busy_overlay = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
"""Keeps the overlay sized and stacked over its target widget."""
|
||||
|
||||
def __init__(self, target: QWidget, overlay: QWidget):
|
||||
super().__init__(target)
|
||||
self._target = target
|
||||
self._overlay = overlay
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._target and event.type() in (
|
||||
QEvent.Resize,
|
||||
QEvent.Show,
|
||||
QEvent.LayoutRequest,
|
||||
QEvent.Move,
|
||||
):
|
||||
self._overlay.setGeometry(self._target.rect())
|
||||
self._overlay.raise_()
|
||||
return False
|
||||
|
||||
|
||||
class BusyLoaderOverlay(QWidget):
|
||||
"""
|
||||
A semi-transparent scrim with centered text and an animated spinner.
|
||||
Call show()/hide() directly, or use via `install_busy_loader(...)`.
|
||||
|
||||
Args:
|
||||
parent(QWidget): The parent widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAutoFillBackground(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self._opacity = opacity
|
||||
|
||||
self._label = QLabel(text, self)
|
||||
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(self._label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
self._label.setFont(f)
|
||||
|
||||
self._spinner = SpinnerWidget(self)
|
||||
self._spinner.setFixedSize(42, 42)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(self._label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
"""
|
||||
Update the overlay text.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
|
||||
"""
|
||||
self._opacity = max(0.0, min(1.0, float(opacity)))
|
||||
# Re-apply alpha using the current theme color
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
|
||||
def update_palette(self):
|
||||
"""
|
||||
Update colors from the current application theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme # type: ignore[attr-defined]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
# Semi-transparent scrim derived from bg
|
||||
self._scrim_color = QColor(self._bg)
|
||||
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self._spinner.update()
|
||||
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
|
||||
self._label.setStyleSheet(f"color: {fg_hex};")
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
) -> BusyLoaderOverlay:
|
||||
"""
|
||||
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
|
||||
|
||||
Args:
|
||||
target(QWidget): The widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
|
||||
)
|
||||
|
||||
self._title = QLabel("Demo Content", self)
|
||||
self._title.setAlignment(Qt.AlignCenter)
|
||||
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(self._title)
|
||||
waveform = Waveform(self)
|
||||
waveform.plot([1, 2, 3, 4, 5])
|
||||
lay.addWidget(waveform, 1)
|
||||
|
||||
QTimer.singleShot(5000, self._ready)
|
||||
|
||||
def _ready(self):
|
||||
self._title.setText("Ready ✓")
|
||||
self.set_busy(False)
|
||||
|
||||
|
||||
class DemoWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget()
|
||||
right = DemoWidget()
|
||||
|
||||
btn_on = QPushButton("Right → Loading")
|
||||
btn_off = QPushButton("Right → Ready")
|
||||
btn_text = QPushButton("Set custom text")
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
|
||||
|
||||
panel = QWidget()
|
||||
prow = QVBoxLayout(panel)
|
||||
prow.addWidget(btn_on)
|
||||
prow.addWidget(btn_off)
|
||||
prow.addWidget(btn_text)
|
||||
prow.addStretch(1)
|
||||
|
||||
central = QWidget()
|
||||
row = QHBoxLayout(central)
|
||||
row.setContentsMargins(12, 12, 12, 12)
|
||||
row.setSpacing(12)
|
||||
row.addWidget(left, 1)
|
||||
row.addWidget(right, 1)
|
||||
row.addWidget(panel, 0)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.resize(900, 420)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._theme import AccentColors
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
@@ -27,14 +29,13 @@ def get_theme_palette():
|
||||
return palette
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors:
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
"""
|
||||
Get the accent colors for the current theme. These colors are extensions of the color palette
|
||||
and are used to highlight specific elements in the UI.
|
||||
"""
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
accent_colors = AccentColors()
|
||||
return accent_colors
|
||||
return None
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -230,8 +229,6 @@ class RPCServer:
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
|
||||
@@ -33,26 +33,6 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
btn.setDefaultAction(toolbar_action.action)
|
||||
btn.setAutoRaise(True)
|
||||
if toolbar_action.text_position == "beside":
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
else:
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
||||
btn.setText(toolbar_action.label_text)
|
||||
toolbar.addWidget(btn)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
@@ -134,39 +114,15 @@ class SeparatorAction(ToolBarAction):
|
||||
|
||||
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(
|
||||
self,
|
||||
standard_icon,
|
||||
tooltip=None,
|
||||
checkable=False,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Action with a standard Qt icon for the toolbar.
|
||||
|
||||
Args:
|
||||
standard_icon: The standard icon from QStyle.
|
||||
tooltip(str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable(bool, optional): Whether the action is checkable. Defaults to False.
|
||||
label_text(str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent(QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
@@ -183,8 +139,6 @@ class MaterialIconAction(ToolBarAction):
|
||||
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.
|
||||
label_text (str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
@@ -195,20 +149,12 @@ class MaterialIconAction(ToolBarAction):
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon.
|
||||
This enables per-action icon text without breaking the existing API.
|
||||
"""
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
@@ -232,10 +178,7 @@ class MaterialIconAction(ToolBarAction):
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
|
||||
@@ -167,7 +167,7 @@ class AdvancedDockArea(BECWidget, QWidget):
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 6)
|
||||
self._root_layout.setSpacing(0)
|
||||
|
||||
# Init Dock Manager
|
||||
@@ -302,6 +302,8 @@ class AdvancedDockArea(BECWidget, QWidget):
|
||||
|
||||
def _setup_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.toolbar.setProperty("variant", "primary")
|
||||
# self.toolbar.setStyleSheet(f"QToolBar {{ border-bottom: 1px; }}")
|
||||
|
||||
PLOT_ACTIONS = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
|
||||
@@ -24,14 +24,7 @@ class CollapsibleSection(QWidget):
|
||||
|
||||
section_reorder_requested = Signal(str, str) # (source_title, target_title)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title="",
|
||||
indentation=10,
|
||||
show_add_button=False,
|
||||
tooltip: str | None = None,
|
||||
):
|
||||
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
@@ -57,8 +50,6 @@ class CollapsibleSection(QWidget):
|
||||
self.header_button.mouseMoveEvent = self._header_mouse_move_event
|
||||
self.header_button.dragEnterEvent = self._header_drag_enter_event
|
||||
self.header_button.dropEvent = self._header_drop_event
|
||||
if tooltip:
|
||||
self.header_button.setToolTip(tooltip)
|
||||
|
||||
self.drag_start_position = None
|
||||
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MacroItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover for macro functions"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.macro_actions: list[Any] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_macro_info = {}
|
||||
|
||||
def add_macro_action(self, action: Any) -> None:
|
||||
"""Add an action for macro functions"""
|
||||
self.macro_actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions"""
|
||||
self.macro_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
# Only show actions for macro functions (not directories)
|
||||
item = index.model().itemFromIndex(index)
|
||||
if not item or not item.data(Qt.ItemDataRole.UserRole):
|
||||
return
|
||||
|
||||
macro_info = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(macro_info, dict) or "function_name" not in macro_info:
|
||||
return
|
||||
|
||||
self.current_macro_info = macro_info
|
||||
|
||||
if self.macro_actions:
|
||||
self._draw_action_buttons(painter, option, self.macro_actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions: list[Any]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
spacing = 2
|
||||
|
||||
# Calculate total width needed for all buttons
|
||||
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
|
||||
|
||||
# Clear previous button rects and create new ones
|
||||
self.button_rects.clear()
|
||||
|
||||
# Calculate starting position (right side of the item)
|
||||
start_x = option.rect.right() - total_width - margin
|
||||
current_x = start_x
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Get theme colors for better integration
|
||||
palette = get_theme_palette()
|
||||
button_bg = palette.button().color()
|
||||
button_bg.setAlpha(150) # Semi-transparent
|
||||
|
||||
for action in actions:
|
||||
if not action.isVisible():
|
||||
continue
|
||||
|
||||
# Calculate button position
|
||||
button_rect = QRect(
|
||||
current_x,
|
||||
option.rect.top() + (option.rect.height() - button_size) // 2,
|
||||
button_size,
|
||||
button_size,
|
||||
)
|
||||
self.button_rects.append(button_rect)
|
||||
|
||||
# Draw button background
|
||||
painter.setBrush(button_bg)
|
||||
painter.setPen(palette.mid().color())
|
||||
painter.drawRoundedRect(button_rect, 3, 3)
|
||||
|
||||
# Draw action icon
|
||||
icon = action.icon()
|
||||
if not icon.isNull():
|
||||
icon_rect = button_rect.adjusted(2, 2, -2, -2)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
# Move to next button position
|
||||
current_x += button_size + spacing
|
||||
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse events for action buttons"""
|
||||
# Early return if not a left click
|
||||
if not (
|
||||
event.type() == event.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
# Check which button was clicked
|
||||
visible_actions = [action for action in self.macro_actions if action.isVisible()]
|
||||
for i, button_rect in enumerate(self.button_rects):
|
||||
if button_rect.contains(event.pos()) and i < len(visible_actions):
|
||||
# Trigger the action
|
||||
visible_actions[i].trigger()
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def set_hovered_index(self, index):
|
||||
"""Set the currently hovered index"""
|
||||
self.hovered_index = index
|
||||
|
||||
|
||||
class MacroTreeWidget(QWidget):
|
||||
"""A tree widget that displays macro functions from Python files"""
|
||||
|
||||
macro_selected = Signal(str, str) # Function name, file path
|
||||
macro_open_requested = Signal(str, str) # Function name, file path
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
# Disable editing to prevent renaming on double-click
|
||||
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
|
||||
|
||||
# Enable mouse tracking for hover effects
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create model for macro functions
|
||||
self.model = QStandardItemModel()
|
||||
self.tree.setModel(self.model)
|
||||
|
||||
# Create and set custom delegate
|
||||
self.delegate = MacroItemDelegate(self.tree)
|
||||
self.tree.setItemDelegate(self.delegate)
|
||||
|
||||
# Add default open button for macros
|
||||
action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self)
|
||||
action.action.triggered.connect(self._on_macro_open_requested)
|
||||
self.delegate.add_macro_action(action.action)
|
||||
|
||||
# Apply BEC styling
|
||||
self._apply_styling()
|
||||
|
||||
# Macro specific properties
|
||||
self.directory = None
|
||||
|
||||
# Connect signals
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
|
||||
# Install event filter for hover tracking
|
||||
self.tree.viewport().installEventFilter(self)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
def _apply_styling(self):
|
||||
"""Apply styling to the tree widget"""
|
||||
# Get theme colors for subtle tree lines
|
||||
palette = get_theme_palette()
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# Standard editable styling
|
||||
opacity_modifier = ""
|
||||
cursor_style = ""
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
{opacity_modifier}
|
||||
{cursor_style}
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QTreeView::item {{
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QTreeView::item:hover {{
|
||||
background: palette(midlight);
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
QTreeView::item:selected {{
|
||||
background: palette(highlight);
|
||||
color: palette(highlighted-text);
|
||||
}}
|
||||
QTreeView::item:selected:hover {{
|
||||
background: palette(highlight);
|
||||
}}
|
||||
"""
|
||||
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse move events for hover tracking"""
|
||||
# Early return if not the tree viewport
|
||||
if obj != self.tree.viewport():
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.MouseMove:
|
||||
index = self.tree.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.delegate.set_hovered_index(index)
|
||||
else:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.Leave:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
"""Set the macros directory and scan for macro functions"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
self._scan_macro_functions()
|
||||
|
||||
def _create_file_item(self, py_file: Path) -> QStandardItem | None:
|
||||
"""Create a file item with its functions
|
||||
|
||||
Args:
|
||||
py_file: Path to the Python file
|
||||
|
||||
Returns:
|
||||
QStandardItem representing the file, or None if no functions found
|
||||
"""
|
||||
# Skip files starting with underscore
|
||||
if py_file.name.startswith("_"):
|
||||
return None
|
||||
|
||||
try:
|
||||
functions = self._extract_functions_from_file(py_file)
|
||||
if not functions:
|
||||
return None
|
||||
|
||||
# Create a file node
|
||||
file_item = QStandardItem(py_file.stem)
|
||||
file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole)
|
||||
|
||||
# Add function nodes
|
||||
for func_name, func_info in functions.items():
|
||||
func_item = QStandardItem(func_name)
|
||||
func_data = {
|
||||
"function_name": func_name,
|
||||
"file_path": str(py_file),
|
||||
"line_number": func_info.get("line_number", 1),
|
||||
"type": "function",
|
||||
}
|
||||
func_item.setData(func_data, Qt.ItemDataRole.UserRole)
|
||||
file_item.appendRow(func_item)
|
||||
|
||||
return file_item
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse {py_file}: {e}")
|
||||
return None
|
||||
|
||||
def _scan_macro_functions(self):
|
||||
"""Scan the directory for Python files and extract macro functions"""
|
||||
self.model.clear()
|
||||
self.model.setHorizontalHeaderLabels(["Macros"])
|
||||
|
||||
if not self.directory or not os.path.exists(self.directory):
|
||||
return
|
||||
|
||||
# Get all Python files in the directory
|
||||
python_files = list(Path(self.directory).glob("*.py"))
|
||||
|
||||
for py_file in python_files:
|
||||
file_item = self._create_file_item(py_file)
|
||||
if file_item:
|
||||
self.model.appendRow(file_item)
|
||||
|
||||
self.tree.expandAll()
|
||||
|
||||
def _extract_functions_from_file(self, file_path: Path) -> dict:
|
||||
"""Extract function definitions from a Python file"""
|
||||
functions = {}
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the AST
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Only get top-level function definitions
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
functions[node.name] = {
|
||||
"line_number": node.lineno,
|
||||
"docstring": ast.get_docstring(node) or "",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse {file_path}: {e}")
|
||||
|
||||
return functions
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Handle item clicks"""
|
||||
item = self.model.itemFromIndex(index)
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
if data.get("type") == "function":
|
||||
function_name = data.get("function_name")
|
||||
file_path = data.get("file_path")
|
||||
if function_name and file_path:
|
||||
logger.info(f"Macro function selected: {function_name} in {file_path}")
|
||||
self.macro_selected.emit(function_name, file_path)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Handle item double-clicks"""
|
||||
item = self.model.itemFromIndex(index)
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
if data.get("type") == "function":
|
||||
function_name = data.get("function_name")
|
||||
file_path = data.get("file_path")
|
||||
if function_name and file_path:
|
||||
logger.info(
|
||||
f"Macro open requested via double-click: {function_name} in {file_path}"
|
||||
)
|
||||
self.macro_open_requested.emit(function_name, file_path)
|
||||
|
||||
def _on_macro_open_requested(self):
|
||||
"""Handle macro open action triggered"""
|
||||
logger.info("Macro open requested")
|
||||
# Early return if no hovered item
|
||||
if not self.delegate.hovered_index.isValid():
|
||||
return
|
||||
|
||||
macro_info = self.delegate.current_macro_info
|
||||
if not macro_info or macro_info.get("type") != "function":
|
||||
return
|
||||
|
||||
function_name = macro_info.get("function_name")
|
||||
file_path = macro_info.get("file_path")
|
||||
if function_name and file_path:
|
||||
self.macro_open_requested.emit(function_name, file_path)
|
||||
|
||||
def add_macro_action(self, action: Any) -> None:
|
||||
"""Add an action for macro items"""
|
||||
self.delegate.add_macro_action(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions from items"""
|
||||
self.delegate.clear_actions()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tree view"""
|
||||
if self.directory is None:
|
||||
return
|
||||
self._scan_macro_functions()
|
||||
|
||||
def refresh_file_item(self, file_path: str):
|
||||
"""Refresh a single file item by re-scanning its functions
|
||||
|
||||
Args:
|
||||
file_path: Path to the Python file to refresh
|
||||
"""
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
logger.warning(f"Cannot refresh file item: {file_path} does not exist")
|
||||
return
|
||||
|
||||
py_file = Path(file_path)
|
||||
|
||||
# Find existing file item in the model
|
||||
existing_item = None
|
||||
existing_row = -1
|
||||
for row in range(self.model.rowCount()):
|
||||
item = self.model.item(row)
|
||||
if not item or not item.data(Qt.ItemDataRole.UserRole):
|
||||
continue
|
||||
item_data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file):
|
||||
existing_item = item
|
||||
existing_row = row
|
||||
break
|
||||
|
||||
# Store expansion state if item exists
|
||||
was_expanded = existing_item and self.tree.isExpanded(existing_item.index())
|
||||
|
||||
# Remove existing item if found
|
||||
if existing_item and existing_row >= 0:
|
||||
self.model.removeRow(existing_row)
|
||||
|
||||
# Create new item using the helper method
|
||||
new_item = self._create_file_item(py_file)
|
||||
if new_item:
|
||||
# Insert at the same position or append if it was a new file
|
||||
insert_row = existing_row if existing_row >= 0 else self.model.rowCount()
|
||||
self.model.insertRow(insert_row, new_item)
|
||||
|
||||
# Restore expansion state
|
||||
if was_expanded:
|
||||
self.tree.expand(new_item.index())
|
||||
else:
|
||||
self.tree.expand(new_item.index())
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all items in the tree"""
|
||||
self.tree.expandAll()
|
||||
|
||||
def collapse_all(self):
|
||||
"""Collapse all items in the tree"""
|
||||
self.tree.collapseAll()
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QPainter
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
@@ -15,20 +15,19 @@ logger = bec_logger.logger
|
||||
class FileItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
def __init__(self, tree_widget):
|
||||
super().__init__(tree_widget)
|
||||
self.setObjectName("file_item_delegate")
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.file_actions = []
|
||||
self.dir_actions = []
|
||||
self.button_rects = []
|
||||
self.file_actions: list[QAction] = []
|
||||
self.dir_actions: list[QAction] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_file_path = ""
|
||||
|
||||
def add_file_action(self, action) -> None:
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action) -> None:
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
@@ -68,7 +67,7 @@ class FileItemDelegate(QStyledItemDelegate):
|
||||
if actions:
|
||||
self._draw_action_buttons(painter, option, actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions):
|
||||
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
@@ -230,18 +229,12 @@ class ScriptTreeWidget(QWidget):
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# Standard editable styling
|
||||
opacity_modifier = ""
|
||||
cursor_style = ""
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
{opacity_modifier}
|
||||
{cursor_style}
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
@@ -364,11 +357,11 @@ class ScriptTreeWidget(QWidget):
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action) -> None:
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action) -> None:
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directory items"""
|
||||
self.delegate.add_dir_action(action)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "cancel"
|
||||
RPC = False
|
||||
RPC = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "restart_alt"
|
||||
RPC = False
|
||||
RPC = True
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
|
||||
@@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "dangerous"
|
||||
RPC = False
|
||||
RPC = True
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
|
||||
@@ -6,13 +6,12 @@ from qtpy.QtCore import QMetaObject, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
@@ -67,29 +66,14 @@ class Ui_availableDeviceResources(object):
|
||||
|
||||
self._add_toolbar()
|
||||
|
||||
# Main area with search and filter using a grid layout
|
||||
self.search_layout = QVBoxLayout()
|
||||
self.grid_layout = QGridLayout()
|
||||
|
||||
self.grouping_selector = QComboBox()
|
||||
self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
lbl_group = QLabel("Group by:")
|
||||
lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.grid_layout.addWidget(lbl_group, 0, 0)
|
||||
self.grid_layout.addWidget(self.grouping_selector, 0, 1)
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
lbl_filter = QLabel("Filter:")
|
||||
lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.grid_layout.addWidget(lbl_filter, 1, 0)
|
||||
self.grid_layout.addWidget(self.search_box, 1, 1)
|
||||
|
||||
self.grid_layout.setColumnStretch(0, 0)
|
||||
self.grid_layout.setColumnStretch(1, 1)
|
||||
|
||||
self.search_layout.addLayout(self.grid_layout)
|
||||
self.search_layout = QHBoxLayout()
|
||||
self.verticalLayout.addLayout(self.search_layout)
|
||||
self.search_layout.addWidget(QLabel("Filter groups: "))
|
||||
self.search_box = QLineEdit()
|
||||
self.search_layout.addWidget(self.search_box)
|
||||
self.search_layout.addWidget(QLabel("Group by: "))
|
||||
self.grouping_selector = QComboBox()
|
||||
self.search_layout.addWidget(self.grouping_selector)
|
||||
|
||||
self.device_groups_list = _ListOfDeviceGroups(
|
||||
availableDeviceResources, AvailableDeviceGroup
|
||||
|
||||
@@ -7,13 +7,22 @@ import json
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Iterable, List
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer
|
||||
from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox
|
||||
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QPoint, QRect, QSize, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
QMessageBox,
|
||||
QStyle,
|
||||
QStyleOption,
|
||||
QStyleOptionViewItem,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
@@ -82,8 +91,8 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate):
|
||||
|
||||
def __init__(self, parent=None, colors=None):
|
||||
super().__init__(parent)
|
||||
colors: AccentColors = colors if colors else get_accent_colors() # type: ignore
|
||||
_icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True)
|
||||
self._colors: AccentColors = colors if colors else get_accent_colors() # type: ignore
|
||||
_icon = partial(material_icon, size=(16, 16), color=self._colors.default, filled=True)
|
||||
self._icon_checked = _icon("check_box")
|
||||
self._icon_unchecked = _icon("check_box_outline_blank")
|
||||
|
||||
@@ -113,12 +122,12 @@ class DeviceValidatedDelegate(CustomDisplayDelegate):
|
||||
|
||||
def __init__(self, parent=None, colors=None):
|
||||
super().__init__(parent)
|
||||
colors = colors if colors else get_accent_colors()
|
||||
self._colors = colors if colors else get_accent_colors()
|
||||
_icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True)
|
||||
self._icons = {
|
||||
ValidationStatus.PENDING: _icon(color=colors.default),
|
||||
ValidationStatus.VALID: _icon(color=colors.success),
|
||||
ValidationStatus.FAILED: _icon(color=colors.emergency),
|
||||
ValidationStatus.PENDING: _icon(color=self._colors.default),
|
||||
ValidationStatus.VALID: _icon(color=self._colors.success),
|
||||
ValidationStatus.FAILED: _icon(color=self._colors.emergency),
|
||||
}
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
@@ -133,6 +142,39 @@ class DeviceValidatedDelegate(CustomDisplayDelegate):
|
||||
painter.drawPixmap(pix_rect.topLeft(), pixmap)
|
||||
|
||||
|
||||
class WrappingTextDelegate(CustomDisplayDelegate):
|
||||
"""Custom delegate for wrapping text in table cells."""
|
||||
|
||||
def __init__(self, table: BECTableView, parent=None):
|
||||
super().__init__(parent)
|
||||
self._table = table
|
||||
|
||||
def _do_custom_paint(self, painter, option, index, value):
|
||||
painter.setClipRect(option.rect)
|
||||
text_option = (
|
||||
Qt.TextFlag.TextWordWrap | Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
painter.drawText(option.rect.adjusted(4, 2, -5, -2), text_option, value)
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
text = str(index.model().data(index, Qt.ItemDataRole.DisplayRole) or "")
|
||||
column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4
|
||||
|
||||
# Avoid pathological heights for too-narrow columns
|
||||
min_width = option.fontMetrics.averageCharWidth() * 4
|
||||
if column_width < min_width:
|
||||
fm = QtGui.QFontMetrics(option.font)
|
||||
return QtCore.QSize(column_width, fm.height() + 4)
|
||||
|
||||
doc = QtGui.QTextDocument()
|
||||
doc.setDefaultFont(option.font)
|
||||
doc.setTextWidth(column_width)
|
||||
doc.setPlainText(text)
|
||||
|
||||
layout_height = doc.documentLayout().documentSize().height()
|
||||
return QtCore.QSize(column_width, int(layout_height) + 4)
|
||||
|
||||
|
||||
class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
"""
|
||||
Custom Device Table Model for managing device configurations.
|
||||
@@ -331,10 +373,9 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
|
||||
def get_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
for cfg in self._device_config:
|
||||
if cfg.get("name") == name:
|
||||
if cfg.get(name) == name:
|
||||
return cfg
|
||||
logger.warning(f"Device {name} does not exist in the model.")
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def _remove_row(self, row: int):
|
||||
@@ -651,31 +692,24 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
# Delegates
|
||||
colors = get_accent_colors()
|
||||
self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors)
|
||||
self.wrap_delegate = WrappingTextDelegate(self.table)
|
||||
self.tool_tip_delegate = DictToolTipDelegate(self.table)
|
||||
self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors)
|
||||
self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus
|
||||
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name
|
||||
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass
|
||||
self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority
|
||||
self.table.setItemDelegateForColumn(
|
||||
4, self.tool_tip_delegate
|
||||
) # deviceTags (was wrap_delegate)
|
||||
self.table.setItemDelegateForColumn(4, self.wrap_delegate) # deviceTags
|
||||
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled
|
||||
self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly
|
||||
|
||||
# Disable wrapping, use eliding, and smooth scrolling
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight)
|
||||
self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
||||
self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
||||
|
||||
# Column resize policies
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags: expand to fill
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # name
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # deviceClass
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # readoutPriority
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly
|
||||
|
||||
@@ -686,7 +720,11 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
# Ensure column widths stay fixed
|
||||
header.setMinimumSectionSize(25)
|
||||
header.setDefaultSectionSize(90)
|
||||
header.setStretchLastSection(False)
|
||||
|
||||
# Enable resizing of column
|
||||
self._geometry_resize_proxy = BECSignalProxy(
|
||||
header.geometriesChanged, rateLimit=10, slot=self._on_table_resized
|
||||
)
|
||||
|
||||
# Selection behavior
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
@@ -695,10 +733,7 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
||||
self.table.horizontalHeader().setHighlightSections(False)
|
||||
|
||||
# Connect model signals to autosize request
|
||||
self._model.rowsInserted.connect(self._request_autosize_columns)
|
||||
self._model.modelReset.connect(self._request_autosize_columns)
|
||||
self._model.dataChanged.connect(self._request_autosize_columns)
|
||||
# Qtimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
|
||||
|
||||
def remove_selected_rows(self):
|
||||
self.table.delete_selected()
|
||||
@@ -715,19 +750,15 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
########### Slot API #################
|
||||
######################################
|
||||
|
||||
def _request_autosize_columns(self, *args):
|
||||
if not hasattr(self, "_autosize_timer"):
|
||||
self._autosize_timer = QtCore.QTimer(self)
|
||||
self._autosize_timer.setSingleShot(True)
|
||||
self._autosize_timer.timeout.connect(self._autosize_columns)
|
||||
self._autosize_timer.start(0)
|
||||
|
||||
@SafeSlot()
|
||||
def _autosize_columns(self):
|
||||
if self._model.rowCount() == 0:
|
||||
return
|
||||
for col in (1, 2, 3):
|
||||
self.table.resizeColumnToContents(col)
|
||||
def _on_table_resized(self, *args):
|
||||
"""Handle changes to the table column resizing."""
|
||||
option = QtWidgets.QStyleOptionViewItem()
|
||||
model = self.table.model()
|
||||
for row in range(model.rowCount()):
|
||||
index = model.index(row, 4)
|
||||
height = self.wrap_delegate.sizeHint(option, index).height()
|
||||
self.table.setRowHeight(row, height)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _handle_shared_selection_signal(self, uuid: str):
|
||||
@@ -839,8 +870,8 @@ if __name__ == "__main__":
|
||||
button.clicked.connect(_button_clicked)
|
||||
# pylint: disable=protected-access
|
||||
config = window.client.device_manager._get_redis_device_config()
|
||||
# names = [cfg.pop("name") for cfg in config]
|
||||
# config_dict = {name: cfg for name, cfg in zip(names, config)}
|
||||
window.set_device_config(config)
|
||||
names = [cfg.pop("name") for cfg in config]
|
||||
config_dict = {name: cfg for name, cfg in zip(names, config)}
|
||||
window.set_device_config(config_dict)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -9,6 +9,7 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
@@ -77,24 +78,6 @@ if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
config_view = DMConfigView()
|
||||
layout.addWidget(config_view)
|
||||
combo_box = QtWidgets.QComboBox()
|
||||
config = config_view.client.device_manager._get_redis_device_config()
|
||||
combo_box.addItems([""] + [str(v) for v, item in enumerate(config)])
|
||||
|
||||
def on_select(text):
|
||||
if text == "":
|
||||
config_view.on_select_config([])
|
||||
else:
|
||||
config_view.on_select_config([config[int(text)]])
|
||||
|
||||
combo_box.currentTextChanged.connect(on_select)
|
||||
layout.addWidget(combo_box)
|
||||
widget.show()
|
||||
config_view.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
import traceback
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -27,45 +26,6 @@ except ImportError:
|
||||
ophyd = None
|
||||
|
||||
|
||||
def docstring_to_markdown(obj) -> str:
|
||||
"""
|
||||
Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown.
|
||||
"""
|
||||
raw = inspect.getdoc(obj) or "*No docstring available.*"
|
||||
|
||||
# Dedent and normalize newlines
|
||||
text = textwrap.dedent(raw).strip()
|
||||
|
||||
md = ""
|
||||
if hasattr(obj, "__name__"):
|
||||
md += f"# {obj.__name__}\n\n"
|
||||
|
||||
# Highlight section headers for Markdown
|
||||
headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"]
|
||||
for h in headers:
|
||||
doc = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text)
|
||||
|
||||
# Preserve code blocks (4+ space indented lines)
|
||||
def fence_code(match: re.Match) -> str:
|
||||
block = re.sub(r"^ {4}", "", match.group(0), flags=re.M)
|
||||
return f"```\n{block}\n```"
|
||||
|
||||
doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text)
|
||||
|
||||
# Preserve normal line breaks for Markdown
|
||||
lines = doc.splitlines()
|
||||
processed_lines = []
|
||||
for line in lines:
|
||||
if line.strip() == "":
|
||||
processed_lines.append("")
|
||||
else:
|
||||
processed_lines.append(line + " ")
|
||||
doc = "\n".join(processed_lines)
|
||||
|
||||
md += doc
|
||||
return md
|
||||
|
||||
|
||||
class DocstringView(QtWidgets.QTextEdit):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
@@ -76,9 +36,60 @@ class DocstringView(QtWidgets.QTextEdit):
|
||||
self.setEnabled(False)
|
||||
return
|
||||
|
||||
def _format_docstring(self, doc: str | None) -> str:
|
||||
if not doc:
|
||||
return "<i>No docstring available.</i>"
|
||||
|
||||
# Escape HTML
|
||||
doc = doc.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
# Remove leading/trailing blank lines from the entire docstring
|
||||
lines = [line.rstrip() for line in doc.splitlines()]
|
||||
while lines and lines[0].strip() == "":
|
||||
lines.pop(0)
|
||||
while lines and lines[-1].strip() == "":
|
||||
lines.pop()
|
||||
doc = "\n".join(lines)
|
||||
|
||||
# Improved regex: match section header + all following indented lines
|
||||
section_regex = re.compile(
|
||||
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
def strip_section(match: re.Match) -> str:
|
||||
# Capture all lines in the match
|
||||
block = match.group(0)
|
||||
lines = block.splitlines()
|
||||
# Remove leading/trailing empty lines within the section
|
||||
lines = [line for line in lines if line.strip() != ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
doc = section_regex.sub(strip_section, doc)
|
||||
|
||||
# Highlight section titles
|
||||
doc = re.sub(
|
||||
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"<b>\1</b>", doc
|
||||
)
|
||||
|
||||
# Convert indented blocks to <pre> and strip leading/trailing newlines
|
||||
def pre_block(match: re.Match) -> str:
|
||||
text = match.group(0).strip("\n")
|
||||
return f"<pre>{text}</pre>"
|
||||
|
||||
doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc)
|
||||
|
||||
# Replace remaining newlines with <br> and collapse multiple <br>
|
||||
doc = doc.replace("\n", "<br>")
|
||||
doc = re.sub(r"(<br>)+", r"<br>", doc)
|
||||
doc = doc.strip("<br>")
|
||||
|
||||
return f"<div style='font-family: sans-serif; font-size: 12pt;'>{doc}</div>"
|
||||
|
||||
def _set_text(self, text: str):
|
||||
self.setReadOnly(False)
|
||||
self.setMarkdown(text)
|
||||
# self.setHtml(self._format_docstring(text))
|
||||
self.setReadOnly(True)
|
||||
|
||||
@SafeSlot(list)
|
||||
@@ -91,15 +102,17 @@ class DocstringView(QtWidgets.QTextEdit):
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device_class(self, device_class_str: str) -> None:
|
||||
docstring = ""
|
||||
if not READY_TO_VIEW:
|
||||
return
|
||||
try:
|
||||
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
|
||||
markdown = docstring_to_markdown(module_cls)
|
||||
self._set_text(markdown)
|
||||
docstring = inspect.getdoc(module_cls)
|
||||
self._set_text(docstring or "No docstring available.")
|
||||
except Exception:
|
||||
logger.exception("Error retrieving docstring")
|
||||
self._set_text(f"*Error retrieving docstring for `{device_class_str}`*")
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error retrieving docstring for {device_class_str}: {content}")
|
||||
self._set_text(f"Error retrieving docstring for {device_class_str}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -108,26 +121,7 @@ if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
config_view = DocstringView()
|
||||
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
|
||||
layout.addWidget(config_view)
|
||||
combo = QtWidgets.QComboBox()
|
||||
combo.addItems(
|
||||
[
|
||||
"",
|
||||
"ophyd_devices.sim.sim_camera.SimCamera",
|
||||
"ophyd.EpicsSignalWithRBV",
|
||||
"ophyd.EpicsMotor",
|
||||
"csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS",
|
||||
]
|
||||
)
|
||||
combo.currentTextChanged.connect(config_view.set_device_class)
|
||||
layout.addWidget(combo)
|
||||
widget.show()
|
||||
config_view.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -83,23 +83,23 @@ class DeviceTester(QtCore.QRunnable):
|
||||
continue
|
||||
with self._lock:
|
||||
if len(self._pending_queue) > 0:
|
||||
item, cfg, connect = self._pending_queue.pop()
|
||||
item, cfg = self._pending_queue.pop()
|
||||
self._active.add(item)
|
||||
fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect)
|
||||
fut = self._test_executor.submit(self._run_test, item, {item: cfg})
|
||||
fut.__dict__["__device_name"] = item
|
||||
fut.add_done_callback(self._done_cb)
|
||||
self._safe_check_and_clear()
|
||||
self._cleanup()
|
||||
|
||||
def submit(self, devices: Iterable[tuple[str, dict, bool]]):
|
||||
def submit(self, devices: Iterable[tuple[str, dict]]):
|
||||
with self._lock:
|
||||
self._pending_queue.extend(devices)
|
||||
self._pending_event.set()
|
||||
|
||||
@staticmethod
|
||||
def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]:
|
||||
def _run_test(name: str, config: dict) -> tuple[str, bool, str]:
|
||||
tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None
|
||||
results = tester.run_with_list_output(connect=connect)
|
||||
results = tester.run_with_list_output(connect=False)
|
||||
return name, results[0].success, results[0].message
|
||||
|
||||
def _safe_check_and_clear(self):
|
||||
@@ -164,6 +164,7 @@ class ValidationListItem(QtWidgets.QWidget):
|
||||
def _start_spinner(self):
|
||||
"""Start the spinner animation."""
|
||||
self._spinner.start()
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
def _stop_spinner(self):
|
||||
"""Stop the spinner animation."""
|
||||
@@ -196,8 +197,6 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
# Signal to emit the validation status of a device
|
||||
device_validated = QtCore.Signal(str, int)
|
||||
# validation_msg in markdown format
|
||||
validation_msg_md = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
@@ -209,18 +208,18 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
self.tester.signals.device_validated.connect(self._on_device_validated)
|
||||
QtCore.QThreadPool.globalInstance().start(self.tester)
|
||||
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
|
||||
# TODO Consider using the thread pool from BECConnector instead of fetching the global instance!
|
||||
self._thread_pool = QtCore.QThreadPool.globalInstance()
|
||||
self._thread_pool = QtCore.QThreadPool(maxThreadCount=1)
|
||||
|
||||
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._main_layout.setSpacing(0)
|
||||
self._main_layout.setSpacing(4)
|
||||
|
||||
# We add a splitter between the list and the text box
|
||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
|
||||
self._main_layout.addWidget(self.splitter)
|
||||
|
||||
self._setup_list_ui()
|
||||
self._setup_textbox_ui()
|
||||
|
||||
def _setup_list_ui(self):
|
||||
"""Setup the list UI."""
|
||||
@@ -230,11 +229,15 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
# Connect signals
|
||||
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
@SafeSlot(list, bool, bool)
|
||||
def change_device_configs(
|
||||
self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False
|
||||
) -> None:
|
||||
def _setup_textbox_ui(self):
|
||||
"""Setup the text box UI."""
|
||||
self._text_box = QtWidgets.QTextEdit(self)
|
||||
self._text_box.setReadOnly(True)
|
||||
self._text_box.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
self.splitter.addWidget(self._text_box)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def change_device_configs(self, device_configs: list[dict[str, Any]], added: bool) -> None:
|
||||
"""Receive an update with device configs.
|
||||
|
||||
Args:
|
||||
@@ -247,7 +250,7 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
continue
|
||||
if self.tester:
|
||||
self._add_device(name, cfg)
|
||||
self.tester.submit([(name, cfg, connect)])
|
||||
self.tester.submit([(name, cfg)])
|
||||
continue
|
||||
if name not in self._device_list_items:
|
||||
continue
|
||||
@@ -311,39 +314,62 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
widget: ValidationListItem = self._list_widget.itemWidget(current)
|
||||
if widget:
|
||||
try:
|
||||
formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg)
|
||||
self.validation_msg_md.emit(formatted_md)
|
||||
formatted_html = self._format_validation_message(widget.validation_msg)
|
||||
self._text_box.setHtml(formatted_html)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"##Error formatting validation message for device {widget.device_name}:\n{e}"
|
||||
)
|
||||
self.validation_msg_md.emit(widget.validation_msg)
|
||||
else:
|
||||
self.validation_msg_md.emit("")
|
||||
logger.error(f"Error formatting validation message: {e}")
|
||||
self._text_box.setPlainText(widget.validation_msg)
|
||||
|
||||
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
|
||||
def _format_validation_message(self, raw_msg: str) -> str:
|
||||
"""Simple HTML formatting for validation messages, wrapping text naturally."""
|
||||
if not raw_msg.strip():
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
return "<i>Validation in progress...</i>"
|
||||
if raw_msg == "Validation in progress...":
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
return "<i>Validation in progress...</i>"
|
||||
|
||||
m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg)
|
||||
device, summary = m.group(1), m.group(2)
|
||||
lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"]
|
||||
raw_msg = escape(raw_msg)
|
||||
|
||||
# Find each field block: \n<field>\n Field required ...
|
||||
field_pat = re.compile(
|
||||
r"\n(?P<field>\w+)\n\s+(?P<rest>Field required.*?(?=\n\w+\n|$))", re.DOTALL
|
||||
# Split into lines
|
||||
lines = raw_msg.splitlines()
|
||||
summary = lines[0] if lines else "Validation Result"
|
||||
rest = "\n".join(lines[1:]).strip()
|
||||
|
||||
# Split traceback / final ERROR
|
||||
tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE)
|
||||
if tb_match:
|
||||
main_text = rest[: tb_match.start()].strip()
|
||||
error_detail = tb_match.group().strip()
|
||||
else:
|
||||
main_text = rest
|
||||
error_detail = ""
|
||||
|
||||
# Highlight field names in orange (simple regex for word: Field)
|
||||
main_text_html = re.sub(
|
||||
r"(\b\w+\b)(?=: Field required)",
|
||||
r'<span style="color:#FF8C00; font-weight:bold;">\1</span>',
|
||||
main_text,
|
||||
)
|
||||
# Wrap in div for monospace, allowing wrapping
|
||||
main_text_html = (
|
||||
f'<div style="white-space: pre-wrap;">{main_text_html}</div>' if main_text_html else ""
|
||||
)
|
||||
|
||||
for m in field_pat.finditer(raw_msg):
|
||||
field = m.group("field")
|
||||
rest = m.group("rest").rstrip()
|
||||
lines.append(f"### {field}")
|
||||
lines.append(rest)
|
||||
# Traceback / error in red
|
||||
error_html = (
|
||||
f'<div style="white-space: pre-wrap; color:#A00000;">{error_detail}</div>'
|
||||
if error_detail
|
||||
else ""
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
# Summary at top, dark red
|
||||
html = (
|
||||
f'<div style="font-family: monospace; font-size:13px; white-space: pre-wrap;">'
|
||||
f'<div style="font-weight:bold; color:#8B0000; margin-bottom:4px;">{summary}</div>'
|
||||
f"{main_text_html}"
|
||||
f"{error_html}"
|
||||
f"</div>"
|
||||
)
|
||||
return html
|
||||
|
||||
def validation_running(self):
|
||||
return self._device_list_items != {}
|
||||
@@ -356,7 +382,6 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
logger.error("Failed to wait for threads to finish. Removing items from the list.")
|
||||
self._device_list_items.clear()
|
||||
self._list_widget.clear()
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the list."""
|
||||
@@ -379,32 +404,12 @@ if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
wid = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(wid)
|
||||
wid.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager_ophyd_test = DMOphydTest()
|
||||
try:
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
import os
|
||||
|
||||
import bec_lib
|
||||
|
||||
config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
|
||||
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
|
||||
device_manager_ophyd_test.change_device_configs(config, True, True)
|
||||
layout.addWidget(device_manager_ophyd_test)
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
|
||||
cfg = yaml_load(config_path)
|
||||
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
device_manager_ophyd_test.add_device_configs(cfg)
|
||||
device_manager_ophyd_test.show()
|
||||
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
|
||||
device_manager_ophyd_test.resize(800, 600)
|
||||
text_box = QtWidgets.QTextEdit()
|
||||
text_box.setReadOnly(True)
|
||||
layout.addWidget(text_box)
|
||||
device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown)
|
||||
wid.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, cast
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.macro_update_handler import has_executable_code
|
||||
from PySide6QtAds import CDockWidget
|
||||
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
|
||||
@@ -26,7 +25,6 @@ class MonacoDock(BECWidget, QWidget):
|
||||
focused_editor = Signal(object) # Emitted when the focused editor changes
|
||||
save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled
|
||||
signature_help = Signal(str) # Emitted when signature help is requested
|
||||
macro_file_updated = Signal(str) # Emitted when a macro file is saved
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
@@ -53,11 +51,6 @@ class MonacoDock(BECWidget, QWidget):
|
||||
dock = CDockWidget(f"Untitled_{count + 1}")
|
||||
dock.setWidget(widget)
|
||||
|
||||
# Connect to modification status changes to update tab titles
|
||||
widget.save_enabled.connect(
|
||||
lambda modified: self._update_tab_title_for_modification(dock, modified)
|
||||
)
|
||||
|
||||
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, True)
|
||||
@@ -90,24 +83,6 @@ class MonacoDock(BECWidget, QWidget):
|
||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||
self.save_enabled.emit(widget.modified)
|
||||
|
||||
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||
"""Update the tab title to show modification status with a dot indicator."""
|
||||
current_title = dock.windowTitle()
|
||||
|
||||
# Remove existing modification indicator (dot and space)
|
||||
if current_title.startswith("• "):
|
||||
base_title = current_title[2:] # Remove "• "
|
||||
else:
|
||||
base_title = current_title
|
||||
|
||||
# Add or remove the modification indicator
|
||||
if modified:
|
||||
new_title = f"• {base_title}"
|
||||
else:
|
||||
new_title = base_title
|
||||
|
||||
dock.setWindowTitle(new_title)
|
||||
|
||||
def _on_signature_change(self, signature: dict):
|
||||
signatures = signature.get("signatures", [])
|
||||
if not signatures:
|
||||
@@ -133,11 +108,8 @@ class MonacoDock(BECWidget, QWidget):
|
||||
self.last_focused_editor = new_widget
|
||||
|
||||
def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget):
|
||||
# Cast widget to MonacoWidget since we know that's what it is
|
||||
monaco_widget = cast(MonacoWidget, widget)
|
||||
|
||||
# Check if we have unsaved changes
|
||||
if monaco_widget.modified:
|
||||
if widget.modified:
|
||||
# Prompt the user to save changes
|
||||
response = QMessageBox.question(
|
||||
self,
|
||||
@@ -148,7 +120,7 @@ class MonacoDock(BECWidget, QWidget):
|
||||
| QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if response == QMessageBox.StandardButton.Yes:
|
||||
self.save_file(monaco_widget)
|
||||
self.save_file(widget)
|
||||
elif response == QMessageBox.StandardButton.Cancel:
|
||||
return
|
||||
|
||||
@@ -156,16 +128,14 @@ class MonacoDock(BECWidget, QWidget):
|
||||
total = len(self.dock_manager.dockWidgets())
|
||||
if total <= 1:
|
||||
# Do not remove the last dock; just wipe its editor content
|
||||
# Temporarily disable read-only mode if the editor is read-only
|
||||
# so we can clear the content for reuse
|
||||
monaco_widget.set_readonly(False)
|
||||
monaco_widget.set_text("")
|
||||
if hasattr(widget, "set_text"):
|
||||
widget.set_text("")
|
||||
dock.setWindowTitle("Untitled")
|
||||
dock.setTabToolTip("Untitled")
|
||||
return
|
||||
|
||||
# Otherwise, proceed to close and delete the dock
|
||||
monaco_widget.close()
|
||||
widget.close()
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
if self.last_focused_editor is dock:
|
||||
@@ -236,7 +206,7 @@ class MonacoDock(BECWidget, QWidget):
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
return new_dock
|
||||
|
||||
def open_file(self, file_name: str, scope: str | None = None) -> None:
|
||||
def open_file(self, file_name: str):
|
||||
"""
|
||||
Open a file in the specified area. If the file is already open, activate it.
|
||||
"""
|
||||
@@ -262,14 +232,12 @@ class MonacoDock(BECWidget, QWidget):
|
||||
editor_dock.setWindowTitle(file)
|
||||
editor_dock.setTabToolTip(file_name)
|
||||
editor_widget.open_file(file_name)
|
||||
editor_widget.metadata["scope"] = scope
|
||||
return
|
||||
|
||||
# File is not open, create a new editor
|
||||
editor_dock = self.add_editor(title=file, tooltip=file_name)
|
||||
widget = cast(MonacoWidget, editor_dock.widget())
|
||||
widget.open_file(file_name)
|
||||
widget.metadata["scope"] = scope
|
||||
|
||||
def save_file(
|
||||
self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True
|
||||
@@ -285,22 +253,11 @@ class MonacoDock(BECWidget, QWidget):
|
||||
widget = self.last_focused_editor.widget() if self.last_focused_editor else None
|
||||
if not widget:
|
||||
return
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
if not self._validate_macros(widget.get_text()):
|
||||
return
|
||||
|
||||
if widget.current_file and not force_save_as:
|
||||
if format_on_save and pathlib.Path(widget.current_file).suffix == ".py":
|
||||
widget.format()
|
||||
|
||||
with open(widget.current_file, "w", encoding="utf-8") as f:
|
||||
f.write(widget.get_text())
|
||||
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
self._update_macros(widget)
|
||||
# Emit signal to refresh macro tree widget
|
||||
self.macro_file_updated.emit(widget.current_file)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
widget._original_content = widget.get_text()
|
||||
widget.save_enabled.emit(False)
|
||||
@@ -321,64 +278,10 @@ class MonacoDock(BECWidget, QWidget):
|
||||
with open(file, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
widget._original_content = text
|
||||
|
||||
# Update the current_file before emitting save_enabled to ensure proper tracking
|
||||
widget._current_file = str(file)
|
||||
widget.save_enabled.emit(False)
|
||||
|
||||
# Find the dock widget containing this monaco widget and update title
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
if dock.widget() == widget:
|
||||
dock.setWindowTitle(file.name)
|
||||
dock.setTabToolTip(str(file))
|
||||
break
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
self._update_macros(widget)
|
||||
# Emit signal to refresh macro tree widget
|
||||
self.macro_file_updated.emit(str(file))
|
||||
|
||||
print(f"Save file called, last focused editor: {self.last_focused_editor}")
|
||||
|
||||
def _validate_macros(self, source: str) -> bool:
|
||||
# pylint: disable=protected-access
|
||||
# Ensure the macro does not contain executable code before saving
|
||||
exec_code, line_number = has_executable_code(source)
|
||||
if exec_code:
|
||||
if line_number is None:
|
||||
msg = "The macro contains executable code. Please remove it before saving."
|
||||
else:
|
||||
msg = f"The macro contains executable code on line {line_number}. Please remove it before saving."
|
||||
QMessageBox.warning(self, "Save Error", msg)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _update_macros(self, widget: MonacoWidget):
|
||||
# pylint: disable=protected-access
|
||||
if not widget.current_file:
|
||||
return
|
||||
# Check which macros have changed and broadcast the change
|
||||
macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file)
|
||||
existing_macros = self.client.macros._update_handler.get_existing_macros(
|
||||
widget.current_file
|
||||
)
|
||||
|
||||
removed_macros = set(existing_macros.keys()) - set(macros.keys())
|
||||
added_macros = set(macros.keys()) - set(existing_macros.keys())
|
||||
for name, info in macros.items():
|
||||
if name in added_macros:
|
||||
self.client.macros._update_handler.broadcast(
|
||||
action="add", name=name, file_path=widget.current_file
|
||||
)
|
||||
if (
|
||||
name in existing_macros
|
||||
and info.get("source", "") != existing_macros[name]["source"]
|
||||
):
|
||||
self.client.macros._update_handler.broadcast(
|
||||
action="reload", name=name, file_path=widget.current_file
|
||||
)
|
||||
for name in removed_macros:
|
||||
self.client.macros._update_handler.broadcast(action="remove", name=name)
|
||||
|
||||
def set_vim_mode(self, enabled: bool):
|
||||
"""
|
||||
Set Vim mode for all editor widgets.
|
||||
@@ -405,41 +308,6 @@ class MonacoDock(BECWidget, QWidget):
|
||||
return widget
|
||||
return None
|
||||
|
||||
def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool:
|
||||
"""
|
||||
Set a specific file's editor to read-only mode.
|
||||
|
||||
Args:
|
||||
file_name (str): The file path to set read-only
|
||||
read_only (bool): Whether to set read-only mode (default: True)
|
||||
|
||||
Returns:
|
||||
bool: True if the file was found and read-only was set, False otherwise
|
||||
"""
|
||||
editor_dock = self._get_editor_dock(file_name)
|
||||
if editor_dock:
|
||||
editor_widget = cast(MonacoWidget, editor_dock.widget())
|
||||
editor_widget.set_readonly(read_only)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_file_icon(self, file_name: str, icon) -> bool:
|
||||
"""
|
||||
Set an icon for a specific file's tab.
|
||||
|
||||
Args:
|
||||
file_name (str): The file path to set icon for
|
||||
icon: The QIcon to set on the tab
|
||||
|
||||
Returns:
|
||||
bool: True if the file was found and icon was set, False otherwise
|
||||
"""
|
||||
editor_dock = self._get_editor_dock(file_name)
|
||||
if editor_dock:
|
||||
editor_dock.setIcon(icon)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@@ -7,7 +7,7 @@ import isort
|
||||
import qtmonaco
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
@@ -59,11 +59,8 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
self.editor.text_changed.connect(self.text_changed.emit)
|
||||
self.editor.text_changed.connect(self._check_save_status)
|
||||
self.editor.initialized.connect(self.apply_theme)
|
||||
self.editor.initialized.connect(self._setup_context_menu)
|
||||
self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action)
|
||||
self._current_file = None
|
||||
self._original_content = ""
|
||||
self.metadata = {}
|
||||
|
||||
@property
|
||||
def current_file(self):
|
||||
@@ -112,7 +109,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
content = self.get_text()
|
||||
try:
|
||||
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
|
||||
except Exception: # black.NothingChanged or other formatting exceptions
|
||||
except black.NothingChanged:
|
||||
formatted_content = content
|
||||
|
||||
config = isort.Config(
|
||||
@@ -291,36 +288,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
return self.editor.get_lsp_header()
|
||||
|
||||
def _setup_context_menu(self):
|
||||
"""Setup custom context menu actions for the Monaco editor."""
|
||||
# Add the "Insert Scan" action to the context menu
|
||||
self.editor.add_action("insert_scan", "Insert Scan", "python")
|
||||
# Add the "Format Code" action to the context menu
|
||||
self.editor.add_action("format_code", "Format Code", "python")
|
||||
|
||||
def _handle_context_menu_action(self, action_id: str):
|
||||
"""Handle context menu action triggers."""
|
||||
if action_id == "insert_scan":
|
||||
self._show_scan_control_dialog()
|
||||
elif action_id == "format_code":
|
||||
self._format_code()
|
||||
|
||||
def _show_scan_control_dialog(self):
|
||||
"""Show the scan control dialog and insert the generated scan code."""
|
||||
# Import here to avoid circular imports
|
||||
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
|
||||
|
||||
dialog = ScanControlDialog(self, client=self.client)
|
||||
if dialog.exec_() == QDialog.DialogCode.Accepted:
|
||||
scan_code = dialog.get_scan_code()
|
||||
if scan_code:
|
||||
# Insert the scan code at the current cursor position
|
||||
self.insert_text(scan_code)
|
||||
|
||||
def _format_code(self):
|
||||
"""Format the current code in the editor."""
|
||||
self.format()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
qapp = QApplication([])
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Scan Control Dialog for Monaco Editor
|
||||
|
||||
This module provides a dialog wrapper around the ScanControl widget,
|
||||
allowing users to configure and generate scan code that can be inserted
|
||||
into the Monaco editor.
|
||||
"""
|
||||
|
||||
from bec_lib.device import Device
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanControlDialog(QDialog):
|
||||
"""
|
||||
Dialog window containing the ScanControl widget for generating scan code.
|
||||
|
||||
This dialog allows users to configure scan parameters and generates
|
||||
Python code that can be inserted into the Monaco editor.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Insert Scan")
|
||||
|
||||
# Store the client for passing to ScanControl
|
||||
self.client = client
|
||||
self._scan_code = ""
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(600, 800)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the dialog UI with ScanControl widget and buttons."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create the scan control widget
|
||||
self.scan_control = ScanControl(parent=self, client=self.client)
|
||||
self.scan_control.show_scan_control_buttons(False)
|
||||
layout.addWidget(self.scan_control)
|
||||
|
||||
# Create dialog buttons
|
||||
button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self)
|
||||
|
||||
# Create custom buttons with appropriate text
|
||||
insert_button = QPushButton("Insert")
|
||||
cancel_button = QPushButton("Cancel")
|
||||
|
||||
button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Connect button signals
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
def _generate_scan_code(self):
|
||||
"""Generate Python code for the configured scan."""
|
||||
try:
|
||||
# Get scan parameters from the scan control widget
|
||||
args, kwargs = self.scan_control.get_scan_parameters()
|
||||
scan_name = self.scan_control.current_scan
|
||||
|
||||
if not scan_name:
|
||||
self._scan_code = ""
|
||||
return
|
||||
|
||||
# Process arguments and add device prefix where needed
|
||||
processed_args = self._process_arguments_for_code_generation(args)
|
||||
processed_kwargs = self._process_kwargs_for_code_generation(kwargs)
|
||||
|
||||
# Generate the Python code string
|
||||
code_parts = []
|
||||
|
||||
# Process arguments and keyword arguments
|
||||
all_args = []
|
||||
|
||||
# Add positional arguments
|
||||
if processed_args:
|
||||
all_args.extend(processed_args)
|
||||
|
||||
# Add keyword arguments (excluding metadata)
|
||||
if processed_kwargs:
|
||||
kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"]
|
||||
all_args.extend(kwargs_strs)
|
||||
|
||||
# Join all arguments and create the scan call
|
||||
args_str = ", ".join(all_args)
|
||||
if args_str:
|
||||
code_parts.append(f"scans.{scan_name}({args_str})")
|
||||
else:
|
||||
code_parts.append(f"scans.{scan_name}()")
|
||||
|
||||
self._scan_code = "\n".join(code_parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating scan code: {e}")
|
||||
self._scan_code = f"# Error generating scan code: {e}\n"
|
||||
|
||||
def _process_arguments_for_code_generation(self, args):
|
||||
"""Process arguments to add device prefixes and proper formatting."""
|
||||
return [self._format_value_for_code(arg) for arg in args]
|
||||
|
||||
def _process_kwargs_for_code_generation(self, kwargs):
|
||||
"""Process keyword arguments to add device prefixes and proper formatting."""
|
||||
return {key: self._format_value_for_code(value) for key, value in kwargs.items()}
|
||||
|
||||
def _format_value_for_code(self, value):
|
||||
"""Format a single value for code generation."""
|
||||
if isinstance(value, Device):
|
||||
return f"dev.{value.name}"
|
||||
return repr(value)
|
||||
|
||||
def get_scan_code(self) -> str:
|
||||
"""
|
||||
Get the generated scan code.
|
||||
|
||||
Returns:
|
||||
str: The Python code for the configured scan.
|
||||
"""
|
||||
return self._scan_code
|
||||
|
||||
def accept(self):
|
||||
"""Override accept to generate code before closing."""
|
||||
self._generate_scan_code()
|
||||
super().accept()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
dialog = ScanControlDialog()
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Sequence
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
@@ -309,7 +309,7 @@ class Image(ImageBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
@@ -324,13 +324,10 @@ class Image(ImageBase):
|
||||
if monitor is None or monitor == "":
|
||||
logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed")
|
||||
return None
|
||||
|
||||
if isinstance(monitor, str):
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
elif isinstance(monitor, Sequence):
|
||||
if isinstance(monitor, tuple):
|
||||
self.entry_validator.validate_monitor(monitor[0])
|
||||
else:
|
||||
raise ValueError(f"Invalid monitor type: {type(monitor)}")
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
self.set_image_update(monitor=monitor, type=monitor_type)
|
||||
if color_map is not None:
|
||||
@@ -352,7 +349,7 @@ class Image(ImageBase):
|
||||
if config.monitor is not None:
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
if isinstance(config.monitor, (list, tuple)):
|
||||
if isinstance(config.monitor, tuple):
|
||||
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
|
||||
else:
|
||||
self.device_combo_box.setCurrentText(config.monitor)
|
||||
@@ -457,7 +454,7 @@ class Image(ImageBase):
|
||||
"""
|
||||
|
||||
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
|
||||
if isinstance(monitor, (list, tuple)):
|
||||
if isinstance(monitor, tuple):
|
||||
device = self.dev[monitor[0]]
|
||||
signal = monitor[1]
|
||||
if len(monitor) == 3:
|
||||
@@ -525,7 +522,7 @@ class Image(ImageBase):
|
||||
Args:
|
||||
monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals.
|
||||
"""
|
||||
if isinstance(monitor, (list, tuple)):
|
||||
if isinstance(monitor, tuple):
|
||||
if self.subscriptions["main"].source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1])
|
||||
|
||||
@@ -12,8 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class ProgressbarConnections(BaseModel):
|
||||
slot: Literal["on_scan_progress", "on_device_readback", None] = None
|
||||
endpoint: EndpointInfo | str | None = None
|
||||
slot: Literal["on_scan_progress", "on_device_readback"] = None
|
||||
endpoint: EndpointInfo | str = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("endpoint")
|
||||
@@ -222,10 +222,9 @@ class Ring(BECConnector, QObject):
|
||||
device(str): Device name for the device readback mode, only used when mode is "device"
|
||||
"""
|
||||
if mode == "manual":
|
||||
if self.config.connections.slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections.slot = None
|
||||
self.config.connections.endpoint = None
|
||||
elif mode == "scan":
|
||||
|
||||
@@ -22,9 +22,13 @@ class RingProgressBarConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field(
|
||||
"plasma", description="Color scheme for the progress bars.", validate_default=True
|
||||
)
|
||||
min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.")
|
||||
max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.")
|
||||
num_bars: int = Field(1, description="Number of progress bars to display.")
|
||||
min_number_of_bars: int | None = Field(
|
||||
1, description="Minimum number of progress bars to display."
|
||||
)
|
||||
max_number_of_bars: int | None = Field(
|
||||
10, description="Maximum number of progress bars to display."
|
||||
)
|
||||
num_bars: int | None = Field(1, description="Number of progress bars to display.")
|
||||
gap: int | None = Field(20, description="Gap between progress bars.")
|
||||
auto_updates: bool | None = Field(
|
||||
True, description="Enable or disable updates based on scan queue status."
|
||||
@@ -241,7 +245,7 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
for i, ring in enumerate(self._rings):
|
||||
ring.config.index = i
|
||||
|
||||
def set_precision(self, precision: int, bar_index: int | None = None):
|
||||
def set_precision(self, precision: int, bar_index: int = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
@@ -270,9 +274,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
|
||||
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
|
||||
"""
|
||||
if isinstance(min_values, (int, float)):
|
||||
if isinstance(min_values, int) or isinstance(min_values, float):
|
||||
min_values = [min_values]
|
||||
if isinstance(max_values, (int, float)):
|
||||
if isinstance(max_values, int) or isinstance(max_values, float):
|
||||
max_values = [max_values]
|
||||
min_values = self._adjust_list_to_bars(min_values)
|
||||
max_values = self._adjust_list_to_bars(max_values)
|
||||
@@ -440,10 +444,14 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
"""
|
||||
found_ring = None
|
||||
for ring in self._rings:
|
||||
if ring.config.index == index:
|
||||
return ring
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
found_ring = ring
|
||||
break
|
||||
if found_ring is None:
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
return found_ring
|
||||
|
||||
def enable_auto_updates(self, enable: bool = True):
|
||||
"""
|
||||
@@ -480,30 +488,29 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
primary_queue = msg.get("queue").get("primary")
|
||||
info = primary_queue.get("info", None)
|
||||
|
||||
if not info:
|
||||
return
|
||||
active_request_block = info[0].get("active_request_block", None)
|
||||
if not active_request_block:
|
||||
return
|
||||
report_instructions = active_request_block.get("report_instructions", None)
|
||||
if not report_instructions:
|
||||
return
|
||||
if info:
|
||||
active_request_block = info[0].get("active_request_block", None)
|
||||
if active_request_block:
|
||||
report_instructions = active_request_block.get("report_instructions", None)
|
||||
if report_instructions:
|
||||
instruction_type = list(report_instructions[0].keys())[0]
|
||||
if instruction_type == "scan_progress":
|
||||
self._hook_scan_progress(ring_index=0)
|
||||
elif instruction_type == "readback":
|
||||
devices = report_instructions[0].get("readback").get("devices")
|
||||
start = report_instructions[0].get("readback").get("start")
|
||||
end = report_instructions[0].get("readback").get("end")
|
||||
if self.config.num_bars != len(devices):
|
||||
self.set_number_of_bars(len(devices))
|
||||
for index, device in enumerate(devices):
|
||||
self._hook_readback(index, device, start[index], end[index])
|
||||
else:
|
||||
logger.error(f"{instruction_type} not supported yet.")
|
||||
|
||||
instruction_type = list(report_instructions[0].keys())[0]
|
||||
if instruction_type == "scan_progress":
|
||||
self._hook_scan_progress(ring_index=0)
|
||||
elif instruction_type == "readback":
|
||||
devices = report_instructions[0].get("readback").get("devices")
|
||||
start = report_instructions[0].get("readback").get("start")
|
||||
end = report_instructions[0].get("readback").get("end")
|
||||
if self.config.num_bars != len(devices):
|
||||
self.set_number_of_bars(len(devices))
|
||||
for index, device in enumerate(devices):
|
||||
self._hook_readback(index, device, start[index], end[index])
|
||||
else:
|
||||
logger.error(f"{instruction_type} not supported yet.")
|
||||
# elif instruction_type == "device_progress":
|
||||
# print("hook device_progress")
|
||||
|
||||
def _hook_scan_progress(self, ring_index: int | None = None):
|
||||
def _hook_scan_progress(self, ring_index: int = None):
|
||||
"""
|
||||
Hook the scan progress to the progress bars.
|
||||
|
||||
@@ -517,7 +524,8 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
|
||||
if ring.config.connections.slot == "on_scan_progress":
|
||||
return
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
else:
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
|
||||
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
|
||||
"""
|
||||
@@ -571,8 +579,6 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
return bar_index
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self._rings:
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
size = min(self.width(), self.height())
|
||||
@@ -625,8 +631,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
return QSize(10, 10)
|
||||
ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)]
|
||||
total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1)
|
||||
diameter = max(total_width * 2, 50)
|
||||
|
||||
diameter = total_width * 2
|
||||
if diameter < 50:
|
||||
diameter = 50
|
||||
return QSize(diameter, diameter)
|
||||
|
||||
def sizeHint(self):
|
||||
|
||||
@@ -154,30 +154,20 @@ class DeviceConfigDialog(QDialog):
|
||||
return super().accept()
|
||||
|
||||
|
||||
class EpicsMotorConfig(BaseModel):
|
||||
class EpicsDeviceConfig(BaseModel):
|
||||
prefix: str
|
||||
|
||||
|
||||
class EpicsSignalROConfig(BaseModel):
|
||||
read_pv: str
|
||||
|
||||
|
||||
class EpicsSignalConfig(BaseModel):
|
||||
read_pv: str
|
||||
write_pv: str | None = None
|
||||
|
||||
|
||||
class PresetClassDeviceConfigDialog(DeviceConfigDialog):
|
||||
def __init__(self, *, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._device_models = {
|
||||
"EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}),
|
||||
"EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}),
|
||||
"EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}),
|
||||
"Custom": (None, {}),
|
||||
}
|
||||
self._create_selection_box()
|
||||
self._selection_box.currentTextChanged.connect(self._replace_form)
|
||||
self._device_models = {
|
||||
"Custom": (None, {}),
|
||||
"EpicsMotor": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}),
|
||||
"EpicsSignal": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}),
|
||||
}
|
||||
|
||||
def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]):
|
||||
for field_name, (value, editable) in constraints.items():
|
||||
@@ -200,7 +190,7 @@ class PresetClassDeviceConfigDialog(DeviceConfigDialog):
|
||||
def _create_selection_box(self):
|
||||
layout = QHBoxLayout()
|
||||
self._selection_box = QComboBox()
|
||||
self._selection_box.addItems(list(self._device_models.keys()))
|
||||
self._selection_box.addItems(["Custom", "EpicsMotor", "EpicsSignal"])
|
||||
layout.addWidget(QLabel("Choose a device class: "))
|
||||
layout.addWidget(self._selection_box)
|
||||
self._layout.insertLayout(0, layout)
|
||||
@@ -324,12 +314,12 @@ class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog):
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
self._spinner.start()
|
||||
QApplication.processEvents() # TODO check if this kills performance and scheduling!
|
||||
QApplication.processEvents()
|
||||
|
||||
def _stop_waiting_display(self):
|
||||
self._overlay_widget.setVisible(False)
|
||||
self._spinner.stop()
|
||||
QApplication.processEvents() # TODO check if this kills performance and scheduling!
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import datetime
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
|
||||
|
||||
@@ -12,7 +9,6 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
from bec_widgets.widgets.containers.explorer.explorer import Explorer
|
||||
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
|
||||
@@ -27,14 +23,14 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._sections = [] # Use list to maintain order instead of set
|
||||
self._sections = set()
|
||||
self.main_explorer = Explorer(parent=self)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.main_explorer)
|
||||
self.setLayout(layout)
|
||||
self.sections = ["scripts", "macros"]
|
||||
self.sections = ["scripts"]
|
||||
|
||||
@SafeProperty(list)
|
||||
def sections(self):
|
||||
@@ -43,16 +39,10 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
@sections.setter
|
||||
def sections(self, value):
|
||||
existing_sections = set(self._sections)
|
||||
new_sections = set(value)
|
||||
# Find sections to add, maintaining the order from the input value list
|
||||
sections_to_add = [
|
||||
section for section in value if section in (new_sections - existing_sections)
|
||||
]
|
||||
self._sections = list(value) # Store as ordered list
|
||||
self._update_section_visibility(sections_to_add)
|
||||
self._sections = set(value)
|
||||
self._update_section_visibility(self._sections - existing_sections)
|
||||
|
||||
def _update_section_visibility(self, sections):
|
||||
# sections is now an ordered list, not a set
|
||||
for section in sections:
|
||||
self._add_section(section)
|
||||
|
||||
@@ -60,8 +50,6 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
match section_name.lower():
|
||||
case "scripts":
|
||||
self.add_script_section()
|
||||
case "macros":
|
||||
self.add_macro_section()
|
||||
case _:
|
||||
pass
|
||||
|
||||
@@ -94,89 +82,21 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
|
||||
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
|
||||
return
|
||||
shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
|
||||
shared_script_section.setToolTip("Shared scripts (read-only)")
|
||||
shared_script_section = CollapsibleSection(title="Shared", parent=self)
|
||||
shared_script_widget = ScriptTreeWidget(parent=self)
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
shared_script_widget.set_directory(plugin_scripts_dir)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared)
|
||||
shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared)
|
||||
# macros_section = CollapsibleSection("MACROS", indentation=0)
|
||||
# macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
# self.main_explorer.add_section(macros_section)
|
||||
|
||||
def add_macro_section(self):
|
||||
section = CollapsibleSection(
|
||||
parent=self,
|
||||
title="MACROS",
|
||||
indentation=0,
|
||||
show_add_button=True,
|
||||
tooltip="Macros are reusable functions that can be called from scripts or the console.",
|
||||
)
|
||||
section.header_add_button.setIcon(material_icon("refresh", size=(20, 20)))
|
||||
section.header_add_button.setToolTip("Reload all macros")
|
||||
section.header_add_button.clicked.connect(self._reload_macros)
|
||||
|
||||
macro_explorer = Explorer(parent=self)
|
||||
macro_widget = MacroTreeWidget(parent=self)
|
||||
macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local)
|
||||
macro_widget.macro_selected.connect(self._emit_file_preview_macros_local)
|
||||
local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
local_macros_section.header_add_button.clicked.connect(self._add_local_macro)
|
||||
local_macros_section.set_widget(macro_widget)
|
||||
local_macro_dir = self.client._service_config.model.user_macros.base_path
|
||||
if not os.path.exists(local_macro_dir):
|
||||
os.makedirs(local_macro_dir)
|
||||
macro_widget.set_directory(local_macro_dir)
|
||||
macro_explorer.add_section(local_macros_section)
|
||||
|
||||
section.set_widget(macro_explorer)
|
||||
self.main_explorer.add_section(section)
|
||||
|
||||
plugin_macros_dir = None
|
||||
plugins = importlib.metadata.entry_points(group="bec")
|
||||
for plugin in plugins:
|
||||
if plugin.name == "plugin_bec":
|
||||
plugin = plugin.load()
|
||||
plugin_macros_dir = os.path.join(plugin.__path__[0], "macros")
|
||||
break
|
||||
|
||||
if not plugin_macros_dir or not os.path.exists(plugin_macros_dir):
|
||||
return
|
||||
shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
|
||||
shared_macro_section.setToolTip("Shared macros (read-only)")
|
||||
shared_macro_widget = MacroTreeWidget(parent=self)
|
||||
shared_macro_section.set_widget(shared_macro_widget)
|
||||
shared_macro_widget.set_directory(plugin_macros_dir)
|
||||
macro_explorer.add_section(shared_macro_section)
|
||||
shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared)
|
||||
shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared)
|
||||
|
||||
def _emit_file_open_scripts_local(self, file_name: str):
|
||||
self.file_open_requested.emit(file_name, "scripts/local")
|
||||
|
||||
def _emit_file_preview_scripts_local(self, file_name: str):
|
||||
self.file_preview_requested.emit(file_name, "scripts/local")
|
||||
|
||||
def _emit_file_open_scripts_shared(self, file_name: str):
|
||||
self.file_open_requested.emit(file_name, "scripts/shared")
|
||||
|
||||
def _emit_file_preview_scripts_shared(self, file_name: str):
|
||||
self.file_preview_requested.emit(file_name, "scripts/shared")
|
||||
|
||||
def _emit_file_open_macros_local(self, function_name: str, file_path: str):
|
||||
self.file_open_requested.emit(file_path, "macros/local")
|
||||
|
||||
def _emit_file_preview_macros_local(self, function_name: str, file_path: str):
|
||||
self.file_preview_requested.emit(file_path, "macros/local")
|
||||
|
||||
def _emit_file_open_macros_shared(self, function_name: str, file_path: str):
|
||||
self.file_open_requested.emit(file_path, "macros/shared")
|
||||
|
||||
def _emit_file_preview_macros_shared(self, function_name: str, file_path: str):
|
||||
self.file_preview_requested.emit(file_path, "macros/shared")
|
||||
|
||||
def _add_local_script(self):
|
||||
"""Show a dialog to enter the name of a new script and create it."""
|
||||
|
||||
@@ -227,134 +147,6 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
# Show error if file creation failed
|
||||
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
|
||||
|
||||
def _add_local_macro(self):
|
||||
"""Show a dialog to enter the name of a new macro function and create it."""
|
||||
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
macro_dir_section = target_section.content_widget.get_section("Local")
|
||||
|
||||
local_macro_dir = macro_dir_section.content_widget.directory
|
||||
|
||||
# Prompt user for function name
|
||||
function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:")
|
||||
|
||||
if not ok or not function_name:
|
||||
return # User cancelled or didn't enter a name
|
||||
|
||||
# Sanitize function name
|
||||
function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name)
|
||||
if not function_name or function_name[0].isdigit():
|
||||
QMessageBox.warning(
|
||||
self, "Invalid Name", "Function name must be a valid Python identifier."
|
||||
)
|
||||
return
|
||||
|
||||
# Create filename based on function name
|
||||
filename = f"{function_name}.py"
|
||||
file_path = os.path.join(local_macro_dir, filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(file_path):
|
||||
response = QMessageBox.question(
|
||||
self,
|
||||
"File exists",
|
||||
f"The file '{filename}' already exists. Do you want to overwrite it?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if response != QMessageBox.StandardButton.Yes:
|
||||
return # User chose not to overwrite
|
||||
|
||||
try:
|
||||
# Create the file with a macro function template
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f'''"""
|
||||
{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
|
||||
|
||||
def {function_name}():
|
||||
"""
|
||||
Description of what this macro does.
|
||||
|
||||
Add your macro implementation here.
|
||||
"""
|
||||
print(f"Executing macro: {function_name}")
|
||||
# TODO: Add your macro code here
|
||||
pass
|
||||
'''
|
||||
)
|
||||
|
||||
# Refresh the macro tree to show the new function
|
||||
macro_dir_section.content_widget.refresh()
|
||||
|
||||
except Exception as e:
|
||||
# Show error if file creation failed
|
||||
QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}")
|
||||
|
||||
def _reload_macros(self):
|
||||
"""Reload all macros using the BEC client."""
|
||||
try:
|
||||
if hasattr(self.client, "macros"):
|
||||
self.client.macros.load_all_user_macros()
|
||||
|
||||
# Refresh the macro tree widgets to show updated functions
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
if target_section and hasattr(target_section, "content_widget"):
|
||||
local_section = target_section.content_widget.get_section("Local")
|
||||
if local_section and hasattr(local_section, "content_widget"):
|
||||
local_section.content_widget.refresh()
|
||||
|
||||
shared_section = target_section.content_widget.get_section("Shared")
|
||||
if shared_section and hasattr(shared_section, "content_widget"):
|
||||
shared_section.content_widget.refresh()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "Reload Macros", "Macros have been reloaded successfully."
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}")
|
||||
|
||||
def refresh_macro_file(self, file_path: str):
|
||||
"""Refresh a single macro file in the tree widget.
|
||||
|
||||
Args:
|
||||
file_path: Path to the macro file that was updated
|
||||
"""
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
if not target_section or not hasattr(target_section, "content_widget"):
|
||||
return
|
||||
|
||||
# Determine if this is a local or shared macro based on the file path
|
||||
local_section = target_section.content_widget.get_section("Local")
|
||||
shared_section = target_section.content_widget.get_section("Shared")
|
||||
|
||||
# Check if file belongs to local macros directory
|
||||
if (
|
||||
local_section
|
||||
and hasattr(local_section, "content_widget")
|
||||
and hasattr(local_section.content_widget, "directory")
|
||||
):
|
||||
local_macro_dir = local_section.content_widget.directory
|
||||
if local_macro_dir and file_path.startswith(local_macro_dir):
|
||||
local_section.content_widget.refresh_file_item(file_path)
|
||||
return
|
||||
|
||||
# Check if file belongs to shared macros directory
|
||||
if (
|
||||
shared_section
|
||||
and hasattr(shared_section, "content_widget")
|
||||
and hasattr(shared_section.content_widget, "directory")
|
||||
):
|
||||
shared_macro_dir = shared_section.content_widget.directory
|
||||
if shared_macro_dir and file_path.startswith(shared_macro_dir):
|
||||
shared_section.content_widget.refresh_file_item(file_path)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -8,6 +8,7 @@ import numpy as np
|
||||
from bec_lib.device import Device, Signal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -480,11 +481,6 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._custom_label if self._custom_label else f"{self._default_label}:"
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
self.disconnect_device()
|
||||
self._device_obj = None
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.39.0"
|
||||
version = "2.38.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -28,9 +28,6 @@ dependencies = [
|
||||
"darkdetect~=0.8",
|
||||
"PySide6-QtAds==4.4.0",
|
||||
"pylsp-bec",
|
||||
"copier~=9.7",
|
||||
"typer~=0.15",
|
||||
"markdown~=3.9",
|
||||
]
|
||||
|
||||
|
||||
@@ -48,6 +45,7 @@ dev = [
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -139,6 +139,25 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the AbortButton widget."""
|
||||
gui: BECGuiClient = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton)
|
||||
dock: client.BECDock
|
||||
widget: client.AbortButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Try detaching the dock
|
||||
dock.detach()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECProgressBar widget."""
|
||||
@@ -352,13 +371,6 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
|
||||
) # Get last image from Redis monitor 2D endpoint
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Now add a device with a preview signal
|
||||
img = widget.image(["eiger", "preview"])
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
@@ -565,13 +577,6 @@ def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_g
|
||||
dock: client.BECDock
|
||||
widget: client.RingProgressBar
|
||||
|
||||
widget.set_number_of_bars(3)
|
||||
widget.rings[0].set_update("manual")
|
||||
widget.rings[0].set_value(30)
|
||||
widget.rings[0].set_min_max_values(0, 100)
|
||||
widget.rings[1].set_update("scan")
|
||||
widget.rings[2].set_update("device", device="samx")
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
@@ -618,6 +623,53 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton)
|
||||
dock: client.BECDock
|
||||
widget: client.StopButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton)
|
||||
dock: client.BECDock
|
||||
widget: client.ResumeButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton)
|
||||
dock: client.BECDock
|
||||
widget: client.ResetButton
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the TextBox widget"""
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
class _TestBusyWidget(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
theme_update: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
theme_update=theme_update,
|
||||
start_busy=start_busy,
|
||||
busy_text=busy_text,
|
||||
**kwargs,
|
||||
)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(QLabel("content", self))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_busy(qtbot, mocked_client):
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…")
|
||||
qtbot.addWidget(w)
|
||||
w.resize(320, 200)
|
||||
w.show()
|
||||
qtbot.waitExposed(w)
|
||||
return w
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_idle(qtbot):
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=False)
|
||||
qtbot.addWidget(w)
|
||||
w.resize(320, 200)
|
||||
w.show()
|
||||
qtbot.waitExposed(w)
|
||||
return w
|
||||
|
||||
|
||||
def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy):
|
||||
overlay = getattr(widget_busy, "_busy_overlay", None)
|
||||
assert overlay is not None, "BECWidget should create a busy overlay in __init__"
|
||||
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
|
||||
def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle):
|
||||
overlay = getattr(widget_idle, "_busy_overlay", None)
|
||||
assert overlay is None, "Overlay should be lazily created when idle"
|
||||
|
||||
widget_idle.set_busy(True, "Fetching data…")
|
||||
overlay = getattr(widget_idle, "_busy_overlay")
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Fetching data…"
|
||||
|
||||
widget_idle.set_busy(False)
|
||||
qtbot.waitUntil(lambda: overlay.isHidden())
|
||||
|
||||
|
||||
def test_becwidget_overlay_tracks_resize(qtbot, widget_busy):
|
||||
overlay = getattr(widget_busy, "_busy_overlay")
|
||||
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
|
||||
|
||||
widget_busy.resize(480, 260)
|
||||
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
|
||||
|
||||
|
||||
def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy):
|
||||
overlay = getattr(widget_busy, "_busy_overlay")
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
frame = getattr(overlay, "_frame", None)
|
||||
assert frame is not None, "Busy overlay must use an internal QFrame for visuals"
|
||||
|
||||
# Insets are 10 px in the implementation
|
||||
outer = overlay.rect()
|
||||
# Ensure resizeEvent has run and frame geometry is updated
|
||||
qtbot.waitUntil(
|
||||
lambda: frame.geometry().width() == outer.width() - 20
|
||||
and frame.geometry().height() == outer.height() - 20
|
||||
)
|
||||
|
||||
inner = frame.geometry()
|
||||
assert inner.left() == outer.left() + 10
|
||||
assert inner.top() == outer.top() + 10
|
||||
assert inner.right() == outer.right() - 10
|
||||
assert inner.bottom() == outer.bottom() - 10
|
||||
|
||||
# Style: dashed border + semi-transparent grey background
|
||||
ss = frame.styleSheet()
|
||||
assert "dashed" in ss
|
||||
assert "border" in ss
|
||||
assert "rgba(128, 128, 128, 110)" in ss
|
||||
|
||||
|
||||
def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle):
|
||||
overlay = getattr(widget_idle, "_busy_overlay", None)
|
||||
assert overlay is None, "Overlay should be created on first text update"
|
||||
|
||||
widget_idle.set_busy_text("Preparing…")
|
||||
overlay = getattr(widget_idle, "_busy_overlay")
|
||||
assert overlay is not None
|
||||
assert overlay.isHidden()
|
||||
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Preparing…"
|
||||
|
||||
|
||||
def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy):
|
||||
overlay = getattr(widget_busy, "_busy_overlay", None)
|
||||
assert overlay is not None, "Busy overlay should exist on a start_busy widget"
|
||||
|
||||
# Initially visible because start_busy=True
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
# Switch OFF
|
||||
widget_busy.set_busy(False)
|
||||
qtbot.waitUntil(lambda: overlay.isHidden())
|
||||
|
||||
# Switch ON again (with new text)
|
||||
widget_busy.set_busy(True, "Back to work…")
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
# Same overlay instance reused (no duplication)
|
||||
assert getattr(widget_busy, "_busy_overlay") is overlay
|
||||
|
||||
# Label updated
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Back to work…"
|
||||
|
||||
# Geometry follows parent after re-show
|
||||
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
|
||||
@@ -144,19 +144,6 @@ class ExamplePlotWidget(BECWidget, QWidget):
|
||||
self.glw.addItem(self.pi)
|
||||
self.pi.plot([1, 2, 3, 4, 5], pen="r")
|
||||
|
||||
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
if item is None:
|
||||
item = self.pi
|
||||
item.vb.menu.close()
|
||||
item.vb.menu.deleteLater()
|
||||
item.ctrlMenu.close()
|
||||
item.ctrlMenu.deleteLater()
|
||||
|
||||
def cleanup(self):
|
||||
self.cleanup_pyqtgraph()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
def test_apply_theme(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, ExamplePlotWidget, client=mocked_client)
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot):
|
||||
form = tab_widget.widget(0).layout().itemAt(0).widget()
|
||||
assert widget.expanded
|
||||
assert (name_field := form.widget_dict.get("name")) is not None
|
||||
qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500)
|
||||
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
assert not widget.expanded
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base):
|
||||
assert device_input_base.devices == []
|
||||
|
||||
|
||||
def test_device_input_base_init_with_config(qtbot, mocked_client):
|
||||
def test_device_input_base_init_with_config(mocked_client):
|
||||
"""Test init with Config"""
|
||||
config = {
|
||||
"widget_class": "DeviceInputWidget",
|
||||
@@ -55,10 +55,6 @@ def test_device_input_base_init_with_config(qtbot, mocked_client):
|
||||
widget2 = DeviceInputWidget(
|
||||
client=mocked_client, config=DeviceInputConfig.model_validate(config)
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.addWidget(widget2)
|
||||
qtbot.waitExposed(widget)
|
||||
qtbot.waitExposed(widget2)
|
||||
for w in [widget, widget2]:
|
||||
assert w.config.gui_id == "test_gui_id"
|
||||
assert w.config.device_filter == ["Positioner"]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QTreeView, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
|
||||
class DummyTree(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
tree = QTreeView(self)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree_widget(qtbot):
|
||||
tree = DummyTree()
|
||||
qtbot.addWidget(tree)
|
||||
qtbot.waitExposed(tree)
|
||||
yield tree
|
||||
|
||||
|
||||
def test_tree_widget_init(tree_widget):
|
||||
assert isinstance(tree_widget, QWidget)
|
||||
Reference in New Issue
Block a user