1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-19 23:05:36 +02:00

Compare commits

..

90 Commits

Author SHA1 Message Date
5c786faaaf feat(help-inspector): add help inspector widget 2025-10-09 15:12:22 +02:00
e4b909cca0 fix(signal_label): dispatcher unsubscribed in the cleanup 2025-10-08 16:10:30 +02:00
d35f802d99 fix(client): abort, reset, stop button removed from RPC access 2025-10-08 16:10:30 +02:00
e7ba29569d test(color_utils): cleanup for pyqtgraph 2025-10-08 16:10:30 +02:00
69568cdfd0 test(device_input_base): added qtbot 2025-10-08 16:10:30 +02:00
44943d5d10 test(busy_loader): tests added 2025-10-08 16:10:30 +02:00
c766f4b84a feat(busy_loader): busy loader added to bec widget base class 2025-10-08 16:10:30 +02:00
bc5424df09 refactor(device_manager_view): added labels to main toolbar 2025-10-03 15:38:11 +02:00
1b35b1b36e fix(available_device_resources): top toolbar size fixed 2025-10-03 15:38:11 +02:00
920e7651b5 perf(device_table_view): text wrapper delegate removed since it was not working correctly anyway 2025-10-03 15:38:11 +02:00
9c14289719 fix(device_manager_view): removed custom styling for overlay 2025-10-03 15:38:11 +02:00
040275ac8b refactor(examples): wrong main app removed 2025-10-03 15:38:11 +02:00
20c94697dd feat(main_app): device manager implemented into main app 2025-10-03 15:38:11 +02:00
5e4d2ec0ef feat(actions): actions can be created with label text with beside or under alignment 2025-10-03 13:56:07 +02:00
8294ef2449 fix: mark processEvents for checks 2025-09-30 14:10:50 +02:00
148b387019 refactor: cleanup 2025-09-30 14:10:50 +02:00
028ba6a684 fix: preset classes for config dialog 2025-09-30 14:10:50 +02:00
f9cc01408d fix: tests 2025-09-30 14:10:50 +02:00
fb2d8ca9d3 style: imports 2025-09-30 14:10:50 +02:00
b65da75f1e refactor: redo device tester 2025-09-30 14:10:50 +02:00
0bb693a062 fix: check plugin exists before loading 2025-09-30 14:10:50 +02:00
33c4527da9 feat: allow setting config in redis 2025-09-30 14:10:50 +02:00
f89b330db3 style: typo 2025-09-30 14:10:50 +02:00
ae7f313fad fix: slightly improve theming 2025-09-30 14:10:50 +02:00
5d148babe5 fix: don't use deprecated api for CDockWidget 2025-09-30 14:10:50 +02:00
63a792aed9 feat(device_manager): add device dialog with presets 2025-09-30 14:10:50 +02:00
f9e21153b6 refactor: genericise config form 2025-09-30 14:10:50 +02:00
7bead79a96 fix: device table theming 2025-09-30 14:10:50 +02:00
eee0ca92a7 refactor: available devices add+remove from toolbar 2025-09-30 14:10:50 +02:00
688b1242e3 fix: add all devices to test list 2025-09-30 14:10:50 +02:00
e93b13ca79 feat: connect available devices to doc and yaml views 2025-09-30 14:10:50 +02:00
f293f1661a feat: add/remove functionality for device table
refactor: use list of configs for general interfaces
2025-09-30 14:10:50 +02:00
6a6fe41f8d refactor: util for MimeData 2025-09-30 14:10:50 +02:00
73c46d47a3 feat(dm): apply shared selection signal util to view 2025-09-30 14:10:50 +02:00
c7cd3c60b4 feat: add shared selection signal util 2025-09-30 14:10:50 +02:00
5cfaeb9efd feat: connect config update to available devices 2025-09-30 14:10:50 +02:00
ced2213e4c fix: allow setting state with other conformation of config 2025-09-30 14:10:50 +02:00
77ea92cd1a feat: prepare available devices for dragging config 2025-09-30 14:10:50 +02:00
53a230c719 feat(device_table): prepare table for drop action 2025-09-30 14:10:50 +02:00
66581b60d1 feat: add available devices to manager view 2025-09-30 14:10:50 +02:00
e618c56c11 fix(dm): add constants.py 2025-09-30 14:10:50 +02:00
b26a568b57 feat: add available device resource browser 2025-09-30 14:10:50 +02:00
95a040522f feat: add ListOfExpandableFrames util 2025-09-30 14:10:50 +02:00
499b4d5615 chore: update qtmonaco dependency 2025-09-30 14:10:50 +02:00
b5c6d93cba refactor: refactor device_manager_view 2025-09-30 14:10:50 +02:00
d92259e8c0 feat(dm-view): initial commit for config_view, ophyd_test and dm_widget 2025-09-30 14:10:50 +02:00
c7a0f531d0 fix(colors): accent colors fetching if theme not provided 2025-09-26 10:47:17 -05:00
e89cefed97 test(main_app): test extended 2025-09-26 10:47:17 -05:00
14d7f1fcad feat(main_app):views with examples for enter and exit hook 2025-09-26 10:47:17 -05:00
49b9cbf553 feat(main_app): main app with interactive app switcher 2025-09-26 10:47:17 -05:00
1803d3dd9d test: remove outdated tests
Note: The stylesheet is now set by qthemes, not the widget itself. As a result, the widget-specific stylesheet remains empty.
2025-09-26 10:47:17 -05:00
a823dd243e feat: add SafeConnect 2025-09-26 10:47:17 -05:00
34ed0daa98 fix: process all deletion events before applying a new theme.
Note: this can be dropped once qthemes is updated.
2025-09-26 10:47:17 -05:00
7c9ba024bc refactor: move to qthemes 1.1.2 2025-09-26 10:47:17 -05:00
8fd091ab44 test: apply theme on qapp creation 2025-09-26 10:47:17 -05:00
84b892d7f0 refactor(spinner): improve enum access 2025-09-26 10:47:17 -05:00
97722bdde7 fix(themes): move apply theme from BECWidget class to server init 2025-09-26 10:47:17 -05:00
63c599db76 fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects 2025-09-26 10:47:17 -05:00
1adabb0955 test: fix tests for qtheme v1 2025-09-26 10:47:17 -05:00
b1d2100e05 fix(serializer): remove deprecated serializer 2025-09-26 10:47:17 -05:00
4420793cf3 ci: add artifact upload 2025-09-26 10:47:17 -05:00
d2fede00d2 test: fixes after theme changes 2025-09-26 10:47:17 -05:00
ff4025c209 build: add missing darkdetect dependency 2025-09-26 10:47:17 -05:00
8f5d28a276 fix(compact_popup): import from qtpy instead of pyside6 2025-09-26 10:47:17 -05:00
1a2ec920f6 chore: fix formatter 2025-09-26 10:47:17 -05:00
098f2d4f6f fix: compact popup layout spacing 2025-09-26 10:47:17 -05:00
706490247b fix: remove pyqtgraph styling logic 2025-09-26 10:47:17 -05:00
a0e190e38d fix: tree items due to pushbutton margins 2025-09-26 10:47:17 -05:00
9aae92aa89 fix: device combobox change paint event to stylesheet change 2025-09-26 10:47:17 -05:00
35f3caf2dd fix(toolbar): toolbar menu button fixed 2025-09-26 10:47:17 -05:00
37191aae62 fix:queue abort button fixed 2025-09-26 10:47:17 -05:00
1feeb11ab0 fix(bec_widgets): adapt to bec_qthemes 1.0 2025-09-26 10:47:17 -05:00
ffa22242d0 build(bec_qthemes): version 1.0 dependency 2025-09-26 10:47:17 -05:00
a32751d368 refactor(advanced_dock_area): profile tools moved to separate module 2025-09-26 10:47:17 -05:00
f60939d231 fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault 2025-09-26 10:47:17 -05:00
fc1e514883 feat(advanced_dock_area): ads has default direction 2025-09-26 10:47:17 -05:00
9e2d0742ca refactor(advanced_dock_area): ads changed to separate widget 2025-09-26 10:47:17 -05:00
16073dfd6d fix(bec_widgets): by default the linux display manager is switched to xcb 2025-09-26 10:47:16 -05:00
410fd517c5 feat(advanced_dock_area): added ads based dock area with profiles 2025-09-26 10:47:16 -05:00
a25781d8d7 refactor(bec_main_window): main app theme renamed to View 2025-09-26 10:47:16 -05:00
9488923381 feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-09-26 10:47:16 -05:00
ad85472698 fix(widget_state_manager): state manager can save to already existing settings
wip widget state manager saving loading file logic
2025-09-26 10:47:16 -05:00
77eb21ac52 fix(widget_state_manager): state manager can save all properties recursively 2025-09-26 10:47:16 -05:00
6f43917cc3 refactor(widget_io): ancestor hierarchy methods consolidated 2025-09-26 10:47:16 -05:00
e45d5da032 feat(widget_io): widget hierarchy find_ancestor added 2025-09-26 10:47:16 -05:00
74f27ec2d9 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-09-26 10:47:16 -05:00
296b858cdd refactor(bec_connector): signals renamed 2025-09-26 10:47:16 -05:00
ab8dfd3811 fix(bec_connector): added name established signal for listeners 2025-09-26 10:47:16 -05:00
b6d4d5d749 fix(bec_connector): dedicated remove signal added for listeners 2025-09-26 10:47:16 -05:00
5a6641f0f9 build: PySide6-QtAds dependency added 2025-09-26 10:47:16 -05:00
89 changed files with 713 additions and 10102 deletions

View File

@@ -53,7 +53,6 @@ runs:
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
sudo apt-get -y install ttyd
- name: Install Python dependencies
shell: bash

View File

@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,125 +1,6 @@
# CHANGELOG
## v2.41.1 (2025-10-15)
### Bug Fixes
- **dependencies**: Bec lib versions fixed
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
## v2.41.0 (2025-10-15)
### Bug Fixes
- **image_roi**: Delete button added to compact version
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
- **image_roi**: Rois can be removed with right click context menu
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
### Build System
- **bec_lib**: Version bump to 3.69.3
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
### Features
- **image_roi_tree**: Compact mode added
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
### Refactoring
- **serializer**: Upgrade to new serializer interface
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
### Testing
- **deviceconfig-form-update**: Add onFailure default to test
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## v2.39.0 (2025-09-24)
### Bug Fixes

View File

@@ -3,7 +3,6 @@ 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.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
@@ -49,7 +48,6 @@ class BECMainApp(BECMainWindow):
self.add_section("BEC Applications", "bec_apps")
self.ads = AdvancedDockArea(self)
self.device_manager = DeviceManagerWidget(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
@@ -61,13 +59,6 @@ class BECMainApp(BECMainWindow):
widget=self.device_manager,
mini_text="DM",
)
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
)
if self._show_examples:
self.add_section("Examples", "examples")
@@ -151,8 +142,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 +195,6 @@ 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()
sys.exit(app.exec())

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtWidgets
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QGraphicsOpacityEffect,
QHBoxLayout,
@@ -80,7 +79,7 @@ class SideBar(QScrollArea):
self.toggle = QToolButton(self)
self.toggle.setCheckable(False)
self.toggle.setIcon(material_icon("keyboard_arrow_right", icon_type=QIcon))
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
self.toggle.clicked.connect(self.on_expand)
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
@@ -153,7 +152,7 @@ class SideBar(QScrollArea):
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
icon_type=QIcon,
convert_to_pixmap=False,
)
)
@@ -199,7 +198,7 @@ class SideBar(QScrollArea):
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
icon_type=QIcon,
convert_to_pixmap=False,
)
)
# Refresh each component that supports it

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from qtpy.QtGui import QIcon
from bec_qthemes import material_icon
from qtpy import QtCore
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
@@ -123,7 +121,7 @@ class NavigationItem(QWidget):
# Main Icon
self.icon_btn = QToolButton(self)
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, icon_type=QIcon))
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
self.icon_btn.setAutoRaise(True)
self._icon_size_collapsed = QtCore.QSize(20, 20)
self._icon_size_expanded = QtCore.QSize(26, 26)
@@ -280,10 +278,12 @@ class NavigationItem(QWidget):
self._toggled = value
if value:
new_icon = material_icon(
self._icon_name, filled=True, color=get_on_primary(), icon_type=QIcon
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
)
else:
new_icon = material_icon(self._icon_name, filled=False, color=get_fg(), icon_type=QIcon)
new_icon = material_icon(
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
)
self.icon_btn.setIcon(new_icon)
# Re-polish so QSS applies correct colors to icon/labels
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
@@ -352,7 +352,7 @@ class DarkModeNavItem(NavigationItem):
self.mini_lbl.setText("Light" if is_dark else "Dark")
# Update icon
self.icon_btn.setIcon(
material_icon("light_mode" if is_dark else "dark_mode", icon_type=QIcon)
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
)
def refresh_theme(self):

View File

@@ -1,63 +0,0 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.applications.views.view import ViewBase
class DeveloperView(ViewBase):
"""
A view for users to write scripts and macros and execute them within the application.
"""
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)
# Apply stretch after the layout is done
self.set_default_view([2, 5, 3], [7, 3])
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_())

View File

@@ -1,347 +0,0 @@
import re
import markdown
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from shiboken6 import isValid
import bec_widgets.widgets.containers.ads as QtAds
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.ads import CDockManager, CDockWidget
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.editors.monaco.monaco_dock 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))
)
self._current_script_id: str | None = None
# 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.DockWidgetFeature.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.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_as_button.action.triggered.connect(self.on_save_as)
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):
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | None):
if value is not None and not isinstance(value, str):
raise ValueError("Script ID must be a string.")
old_script_id = self._current_script_id
self._current_script_id = value
self._update_subscription(value, old_script_id)
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
if old_script_id is not None:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
)
if new_script_id is not None:
self.bec_dispatcher.connect_slot(
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_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)
def cleanup(self):
for dock in self.dock_manager.dockWidgets():
self._delete_dock(dock)
return super().cleanup()
def _delete_dock(self, dock: CDockWidget) -> None:
w = dock.widget()
if w and isValid(w):
w.close()
w.deleteLater()
if isValid(dock):
dock.closeDockWidget()
dock.deleteDockWidget()
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()
_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_())

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import os
from functools import partial
from typing import List, Literal
from typing import List
import PySide6QtAds as QtAds
import yaml
@@ -14,23 +14,10 @@ from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QThreadPool, QTimer
from qtpy.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QTextEdit,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@@ -96,56 +83,6 @@ def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
QTimer.singleShot(0, apply)
class ConfigChoiceDialog(QDialog):
REPLACE = 1
ADD = 2
CANCEL = 0
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Load Config")
layout = QVBoxLayout(self)
label = QLabel("Do you want to replace the current config or add to it?")
label.setWordWrap(True)
layout.addWidget(label)
# Buttons: equal size, stacked vertically
self.replace_btn = QPushButton("Replace")
self.add_btn = QPushButton("Add")
self.cancel_btn = QPushButton("Cancel")
btn_layout = QHBoxLayout()
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
# Connect signals to explicit slots
self.replace_btn.clicked.connect(self.accept_replace)
self.add_btn.clicked.connect(self.accept_add)
self.cancel_btn.clicked.connect(self.reject_cancel)
self._result = self.CANCEL
def accept_replace(self):
self._result = self.REPLACE
self.accept()
def accept_add(self):
self._result = self.ADD
self.accept()
def reject_cancel(self):
self._result = self.CANCEL
self.reject()
def result(self):
return self._result
AVAILABLE_RESOURCE_IS_READY = False
class DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
@@ -162,6 +99,15 @@ class DeviceManagerView(BECWidget, QWidget):
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Available Resources Widget
self.available_devices = AvailableDeviceResources(
self, shared_selection_signal=self._shared_selection
)
self.available_devices_dock = QtAds.CDockWidget(
self.dock_manager, "Available Devices", self
)
self.available_devices_dock.setWidget(self.available_devices)
# Device Table View widget
self.device_table_view = DeviceTableView(
self, shared_selection_signal=self._shared_selection
@@ -184,116 +130,78 @@ class DeviceManagerView(BECWidget, QWidget):
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
# Help Inspector
widget = QWidget(self)
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.help_inspector = HelpInspector(self)
layout.addWidget(self.help_inspector)
text_box = QTextEdit(self)
text_box.setReadOnly(False)
text_box.setPlaceholderText("Help text will appear here...")
layout.addWidget(text_box)
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
self.help_inspector_dock.setWidget(widget)
# Register callback
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
# Error Logs View
self.error_logs_view = QTextEdit(self)
self.error_logs_view.setReadOnly(True)
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
self.error_logs_dock.setWidget(self.error_logs_view)
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
# Arrange widgets within the QtAds dock manager
# Central widget area
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
# Right area - should be pushed into view if something is active
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.ophyd_test_dock_view,
QtAds.DockWidgetArea.BottomDockWidgetArea,
self.dm_docs_view_dock,
self.central_dock_area,
)
# create bottom area (2-arg -> area)
self.bottom_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
)
# YAML view left of docstrings (docks relative to bottom area)
# Left Area
self.left_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area
)
# Error/help area right of docstrings (dock relative to bottom area)
area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.help_inspector_dock,
self.bottom_dock_area,
# Right area
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view
)
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
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)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [7, 3])
# Fetch all dock areas of the dock widgets (on our case always one dock area)
for dock in self.dock_manager.dockWidgets():
area = dock.dockAreaWidget()
area.titleBar().setVisible(False)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [3, 1])
# self.set_default_view([2, 8, 2], [2, 2, 4])
# Connect slots
for signal, slots in [
(
self.device_table_view.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.available_devices.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.ophyd_test_view.device_validated,
(self.device_table_view.update_device_validation,),
),
(
self.device_table_view.device_configs_changed,
(self.ophyd_test_view.change_device_configs,),
(
self.ophyd_test_view.change_device_configs,
self.available_devices.mark_devices_used,
),
),
(
self.available_devices.add_selected_devices,
(self.device_table_view.add_device_configs,),
),
(
self.available_devices.del_selected_devices,
(self.device_table_view.remove_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Once available resource is ready, add it to the view again
if AVAILABLE_RESOURCE_IS_READY:
# Available Resources Widget
self.available_devices = AvailableDeviceResources(
self, shared_selection_signal=self._shared_selection
)
self.available_devices_dock = QtAds.CDockWidget(
self.dock_manager, "Available Devices", self
)
self.available_devices_dock.setWidget(self.available_devices)
# Connect slots for available reosource
for signal, slots in [
(
self.available_devices.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.device_table_view.device_configs_changed,
(self.available_devices.mark_devices_used,),
),
(
self.available_devices.add_selected_devices,
(self.device_table_view.add_device_configs,),
),
(
self.available_devices.del_selected_devices,
(self.device_table_view.remove_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Add toolbar
self._add_toolbar()
def _add_toolbar(self):
@@ -309,9 +217,7 @@ class DeviceManagerView(BECWidget, QWidget):
# Create IO bundle
io_bundle = ToolbarBundle("IO", self.toolbar.components)
# Load from disk
load = MaterialIconAction(
text_position="under",
icon_name="file_open",
parent=self,
tooltip="Load configuration file from disk",
@@ -322,24 +228,22 @@ class DeviceManagerView(BECWidget, QWidget):
io_bundle.add_action("load")
# Add safe to disk
save_to_disk = MaterialIconAction(
text_position="under",
safe_to_disk = MaterialIconAction(
icon_name="file_save",
parent=self,
tooltip="Save config to disk",
label_text="Save Config",
)
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
save_to_disk.action.triggered.connect(self._save_to_disk_action)
io_bundle.add_action("save_to_disk")
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
safe_to_disk.action.triggered.connect(self._save_to_disk_action)
io_bundle.add_action("safe_to_disk")
# Add load config from redis
load_redis = MaterialIconAction(
text_position="under",
icon_name="cached",
parent=self,
tooltip="Load current config from Redis",
label_text="Get Current Config",
label_text="Reload Config",
)
load_redis.action.triggered.connect(self._load_redis_action)
self.toolbar.components.add_safe("load_redis", load_redis)
@@ -347,13 +251,11 @@ class DeviceManagerView(BECWidget, QWidget):
# Update config action
update_config_redis = MaterialIconAction(
text_position="under",
icon_name="cloud_upload",
parent=self,
tooltip="Update current config in Redis",
label_text="Update Config",
)
update_config_redis.action.setEnabled(False)
update_config_redis.action.triggered.connect(self._update_redis_action)
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
io_bundle.add_action("update_config_redis")
@@ -368,7 +270,6 @@ class DeviceManagerView(BECWidget, QWidget):
# Reset composed view
reset_composed = MaterialIconAction(
text_position="under",
icon_name="delete_sweep",
parent=self,
tooltip="Reset current composed config view",
@@ -380,11 +281,7 @@ class DeviceManagerView(BECWidget, QWidget):
# Add device
add_device = MaterialIconAction(
text_position="under",
icon_name="add",
parent=self,
tooltip="Add new device",
label_text="Add Device",
icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device"
)
add_device.action.triggered.connect(self._add_device_action)
self.toolbar.components.add_safe("add_device", add_device)
@@ -392,11 +289,7 @@ class DeviceManagerView(BECWidget, QWidget):
# Remove device
remove_device = MaterialIconAction(
text_position="under",
icon_name="remove",
parent=self,
tooltip="Remove device",
label_text="Remove Device",
icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device"
)
remove_device.action.triggered.connect(self._remove_device_action)
self.toolbar.components.add_safe("remove_device", remove_device)
@@ -404,11 +297,10 @@ class DeviceManagerView(BECWidget, QWidget):
# Rerun validation
rerun_validation = MaterialIconAction(
text_position="under",
icon_name="checklist",
parent=self,
tooltip="Run device validation with 'connect' on selected devices",
label_text="Validate Connection",
label_text="Rerun Validation",
)
rerun_validation.action.triggered.connect(self._rerun_validation_action)
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
@@ -417,6 +309,13 @@ class DeviceManagerView(BECWidget, QWidget):
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
# Most likly, no actions on available devices
# Actions (vielleicht bundle fuer available devices )
# - reset composed view
# - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice)
# - remove device
# - rerun validation (with/without connect)
# IO actions
def _coming_soon(self):
return QMessageBox.question(
@@ -430,6 +329,7 @@ class DeviceManagerView(BECWidget, QWidget):
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
# Check if plugin repo is installed...
try:
plugin_path = plugin_repo_path()
plugin_name = plugin_package_name()
@@ -443,48 +343,18 @@ class DeviceManagerView(BECWidget, QWidget):
# Implement the file loading logic here
start_dir = os.path.abspath(config_path)
file_path = self._get_file_path(start_dir, "open_file")
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
self._load_config_from_file(file_path)
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
if mode == "open_file":
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
else:
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=start_dir
)
return file_path
def _load_config_from_file(self, file_path: str):
"""
Load device config from a given file path and update the device table view.
Args:
file_path (str): Path to the configuration file.
"""
try:
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self._open_config_choice_dialog(config)
def _open_config_choice_dialog(self, config: List[dict]):
"""
Open a dialog to choose whether to replace or add the loaded config.
Args:
config (List[dict]): List of device configurations loaded from the file.
"""
dialog = ConfigChoiceDialog(self)
if dialog.exec():
if dialog.result() == ConfigChoiceDialog.REPLACE:
self.device_table_view.set_device_config(config)
elif dialog.result() == ConfigChoiceDialog.ADD:
self.device_table_view.add_device_configs(config)
try:
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self.device_table_view.set_device_config(
config
) # TODO ADD QDialog with 'replace', 'add' & 'cancel'
# TODO would we ever like to add the current config to an existing composition
@SafeSlot()
@@ -503,7 +373,7 @@ class DeviceManagerView(BECWidget, QWidget):
return
@SafeSlot()
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
def _update_redis_action(self):
"""Action to push the current composition to Redis"""
reply = _yes_no_question(
self,
@@ -520,9 +390,9 @@ class DeviceManagerView(BECWidget, QWidget):
return QMessageBox.warning(
self, "Validation has not completed.", "Please wait for the validation to finish."
)
self._push_composition_to_redis()
self._push_compositiion_to_redis()
def _push_composition_to_redis(self):
def _push_compositiion_to_redis(self):
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
@@ -530,7 +400,7 @@ class DeviceManagerView(BECWidget, QWidget):
@SafeSlot()
def _save_to_disk_action(self):
"""Action for the 'save_to_disk' action to save the current config to disk."""
"""Action for the 'safe_to_disk' action to save the current config to disk."""
# Check if plugin repo is installed...
try:
config_path = self._get_recovery_config_path()
@@ -540,13 +410,16 @@ class DeviceManagerView(BECWidget, QWidget):
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
# Implement the file loading logic here
file_path = self._get_file_path(config_path, "save_file")
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=config_path
)
if file_path:
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
with open(file_path, "w") as file:
file.write(yaml.dump(config))
# Table actions
@SafeSlot()
def _reset_composed_view(self):
"""Action for the 'reset_composed_view' action to reset the composed view."""
@@ -558,10 +431,13 @@ class DeviceManagerView(BECWidget, QWidget):
if reply == QMessageBox.StandardButton.Yes:
self.device_table_view.clear_device_configs()
# TODO Bespoke Form to add a new device
# TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device
# For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc..
# For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required..
@SafeSlot()
def _add_device_action(self):
"""Action for the 'add_device' action to add a new device."""
# Implement the logic to add a new device
dialog = PresetClassDeviceConfigDialog(parent=self)
dialog.accepted_data.connect(self._add_to_table_from_dialog)
dialog.open()
@@ -575,12 +451,13 @@ class DeviceManagerView(BECWidget, QWidget):
"""Action for the 'remove_device' action to remove a device."""
self.device_table_view.remove_selected_rows()
# TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations
# in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'!
@SafeSlot()
@SafeSlot(bool)
def _rerun_validation_action(self, connect: bool = True):
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, connect)
self.ophyd_test_view.change_device_configs(configs, True, True)
####### Default view has to be done with setting up splitters ########
def set_default_view(

View File

@@ -8,7 +8,6 @@ from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from qtpy.QtGui import QIcon
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
@@ -47,13 +46,13 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
)
# Load current config
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), icon_type=QIcon)
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
# Load config from disk
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
icon = material_icon(icon_name="folder", size=(24, 24), icon_type=QIcon)
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
self.button_load_config_from_file.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_config_from_file)
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)

View File

@@ -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

View File

@@ -104,7 +104,6 @@ class AdvancedDockArea(RPCBase):
movable: "bool" = True,
start_floating: "bool" = False,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
**kwargs,
) -> "BECWidget":
"""
Create a new widget (or reuse an instance) and add it as a dock.
@@ -117,7 +116,6 @@ class AdvancedDockArea(RPCBase):
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
**kwargs: The keyword arguments for the widget.
Returns:
The widget instance.
"""
@@ -2686,54 +2684,26 @@ class LogPanel(RPCBase):
class Minesweeper(RPCBase): ...
class MonacoDock(RPCBase):
"""MonacoDock is a dock widget that contains Monaco editor instances."""
@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 MonacoWidget(RPCBase):
"""A simple Monaco editor widget"""
@rpc_call
def set_text(
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
) -> "None":
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
file_name (str): Set the file name
reset (bool): If True, reset the original content to the new text.
"""
@rpc_call
def get_text(self) -> "str":
def get_text(self) -> str:
"""
Get the current text from the Monaco editor.
"""
@rpc_call
def insert_text(
self, text: "str", line: "int | None" = None, column: "int | None" = None
) -> "None":
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
@@ -2744,7 +2714,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def delete_line(self, line: "int | None" = None) -> "None":
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
@@ -2753,16 +2723,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def open_file(self, file_name: "str") -> "None":
"""
Open a file in the editor.
Args:
file_name (str): The path + file name of the file that needs to be displayed.
"""
@rpc_call
def set_language(self, language: "str") -> "None":
def set_language(self, language: str) -> None:
"""
Set the programming language for syntax highlighting in the Monaco editor.
@@ -2771,13 +2732,13 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def get_language(self) -> "str":
def get_language(self) -> str:
"""
Get the current programming language set in the Monaco editor.
"""
@rpc_call
def set_theme(self, theme: "str") -> "None":
def set_theme(self, theme: str) -> None:
"""
Set the theme for the Monaco editor.
@@ -2786,13 +2747,13 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def get_theme(self) -> "str":
def get_theme(self) -> str:
"""
Get the current theme of the Monaco editor.
"""
@rpc_call
def set_readonly(self, read_only: "bool") -> "None":
def set_readonly(self, read_only: bool) -> None:
"""
Set the Monaco editor to read-only mode.
@@ -2803,10 +2764,10 @@ class MonacoWidget(RPCBase):
@rpc_call
def set_cursor(
self,
line: "int",
column: "int" = 1,
move_to_position: "Literal[None, 'center', 'top', 'position']" = None,
) -> "None":
line: int,
column: int = 1,
move_to_position: Literal[None, "center", "top", "position"] = None,
) -> None:
"""
Set the cursor position in the Monaco editor.
@@ -2817,7 +2778,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def current_cursor(self) -> "dict[str, int]":
def current_cursor(self) -> dict[str, int]:
"""
Get the current cursor position in the Monaco editor.
@@ -2826,7 +2787,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def set_minimap_enabled(self, enabled: "bool") -> "None":
def set_minimap_enabled(self, enabled: bool) -> None:
"""
Enable or disable the minimap in the Monaco editor.
@@ -2835,7 +2796,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def set_vim_mode_enabled(self, enabled: "bool") -> "None":
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
@@ -2844,7 +2805,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def set_lsp_header(self, header: "str") -> "None":
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
@@ -2854,7 +2815,7 @@ class MonacoWidget(RPCBase):
"""
@rpc_call
def get_lsp_header(self) -> "str":
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
@@ -5502,8 +5463,6 @@ class Waveform(RPCBase):
color: "str | None" = None,
label: "str | None" = None,
dap: "str | None" = None,
scan_id: "str | None" = None,
scan_number: "int | None" = None,
**kwargs,
) -> "Curve":
"""
@@ -5526,10 +5485,6 @@ class Waveform(RPCBase):
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
Returns:
Curve: The curve object.
@@ -5572,11 +5527,11 @@ class Waveform(RPCBase):
def update_with_scan_history(self, scan_index: "int" = None, scan_id: "str" = None):
"""
Update the scan curves with the data from the scan storage.
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
@rpc_call

View File

@@ -1,23 +1,12 @@
from __future__ import annotations
import ast
import importlib
import os
from typing import Any, Dict
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSplitter,
QTabWidget,
@@ -25,359 +14,149 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
Features:
- Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`.
- Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`.
- Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut.
- Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block.
"""
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self._widgets_by_name: Dict[str, QWidget] = {}
self._init_ui()
# expose helper API and basics in the inprocess console
# console push
if self.console.inprocess is True:
# A thin API wrapper so users have a stable, minimal surface in the console
class _ConsoleAPI:
def __init__(self, win: "JupyterConsoleWindow"):
self._win = win
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None):
"""Add an existing QWidget as a new tab and expose it in the console under `shortcut`."""
return self._win.add_widget(widget, shortcut, title=title)
def add_widget_by_class_path(
self,
class_path: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Import a QWidget class from `class_path`, instantiate it, and add it."""
return self._win.add_widget_by_class_path(
class_path, shortcut, kwargs=kwargs, title=title
)
def add_widget_by_type(
self,
widget_type: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Instantiate a registered BEC widget by type string and add it."""
return self._win.add_widget_by_type(
widget_type, shortcut, kwargs=kwargs, title=title
)
def list_widgets(self):
return list(self._win._widgets_by_name.keys())
def get_widget(self, shortcut: str) -> QWidget | None:
return self._win._widgets_by_name.get(shortcut)
def available_widgets(self):
return list(widget_handler.widget_classes.keys())
self.jc = _ConsoleAPI(self)
self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh})
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"pg": pg,
"wh": wh,
"dock": self.dock,
"im": self.im,
"ads": self.ads,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
}
)
def _init_ui(self):
self.layout = QHBoxLayout(self)
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
# Horizontal splitter
splitter = QSplitter(self)
self.layout.addWidget(splitter)
# Left: tabs that will host dynamically added widgets
self.tab_widget = QTabWidget(splitter)
tab_widget = QTabWidget(splitter)
# Right: console area with an add-widget mini panel on top
right_panel = QGroupBox("Jupyter Console", splitter)
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(6, 12, 6, 6)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# Add-widget mini panel
add_panel = QFrame(right_panel)
shape = QFrame.Shape.StyledPanel # PySide6 style enums
add_panel.setFrameShape(shape)
add_grid = QGridLayout(add_panel)
add_grid.setContentsMargins(8, 8, 8, 8)
add_grid.setHorizontalSpacing(8)
add_grid.setVerticalSpacing(6)
instr = QLabel(
"Add a widget by class path or choose a registered BEC widget type,"
" and expose it in the console under a shortcut.\n"
"Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform"
)
instr.setWordWrap(True)
add_grid.addWidget(instr, 0, 0, 1, 2)
# Registered widget selector
reg_label = QLabel("Registered")
reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.registry_combo = QComboBox(add_panel)
self.registry_combo.setEditable(False)
self.refresh_btn = QPushButton("Refresh")
reg_row = QHBoxLayout()
reg_row.addWidget(self.registry_combo)
reg_row.addWidget(self.refresh_btn)
add_grid.addWidget(reg_label, 1, 0)
add_grid.addLayout(reg_row, 1, 1)
# Class path entry
class_label = QLabel("Class")
class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.class_path_edit = QLineEdit(add_panel)
self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)")
add_grid.addWidget(class_label, 2, 0)
add_grid.addWidget(self.class_path_edit, 2, 1)
# Shortcut
shortcut_label = QLabel("Shortcut")
shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.shortcut_edit = QLineEdit(add_panel)
self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)")
add_grid.addWidget(shortcut_label, 3, 0)
add_grid.addWidget(self.shortcut_edit, 3, 1)
# Kwargs
kwargs_label = QLabel("Kwargs")
kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.kwargs_edit = QLineEdit(add_panel)
self.kwargs_edit.setPlaceholderText(
'Optional kwargs as dict literal, e.g. {"popups": True}'
)
add_grid.addWidget(kwargs_label, 4, 0)
add_grid.addWidget(self.kwargs_edit, 4, 1)
# Title
title_label = QLabel("Title")
title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.title_edit = QLineEdit(add_panel)
self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)")
add_grid.addWidget(title_label, 5, 0)
add_grid.addWidget(self.title_edit, 5, 1)
# Buttons
btn_row = QHBoxLayout()
self.add_btn = QPushButton("Add by class path")
self.add_btn.clicked.connect(self._on_add_widget_clicked)
self.add_reg_btn = QPushButton("Add registered")
self.add_reg_btn.clicked.connect(self._on_add_registered_clicked)
btn_row.addStretch(1)
btn_row.addWidget(self.add_reg_btn)
btn_row.addWidget(self.add_btn)
add_grid.addLayout(btn_row, 6, 0, 1, 2)
# Make the second column expand
add_grid.setColumnStretch(0, 0)
add_grid.setColumnStretch(1, 1)
# Console widget
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# fourth_tab = QWidget()
# fourth_tab_layout = QVBoxLayout(fourth_tab)
# self.pb = PlotBase()
# self.pi = self.pb.plot_item
# fourth_tab_layout.addWidget(self.pb)
# tab_widget.addTab(fourth_tab, "PlotBase")
#
# tab_widget.setCurrentIndex(3)
#
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=True)
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
#
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.ads = AdvancedDockArea(gui_id="ads")
seventh_tab_layout.addWidget(self.ads)
tab_widget.addTab(seventh_tab, "ADS")
tab_widget.setCurrentIndex(2)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
# Vertical splitter between add panel and console
right_splitter = QSplitter(Qt.Vertical, right_panel)
right_splitter.addWidget(add_panel)
right_splitter.addWidget(self.console)
right_splitter.setStretchFactor(0, 0)
right_splitter.setStretchFactor(1, 1)
right_splitter.setSizes([300, 600])
# Put splitter into the right group box
right_layout.addWidget(right_splitter)
# Populate registry on startup
self._populate_registry_widgets()
def _populate_registry_widgets(self):
try:
widget_handler.update_available_widgets()
items = sorted(widget_handler.widget_classes.keys())
except Exception as exc:
print(f"Failed to load registered widgets: {exc}")
items = []
self.registry_combo.clear()
self.registry_combo.addItems(items)
def _on_add_widget_clicked(self):
class_path = self.class_path_edit.text().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
if not class_path or not shortcut:
print("Please provide both class path and shortcut.")
return
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
try:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
except Exception as exc:
print(f"Failed to instantiate {class_path}: {exc}")
return
try:
self.add_widget(widget, shortcut, title=title)
except Exception as exc:
print(f"Failed to add widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _on_add_registered_clicked(self):
widget_type = self.registry_combo.currentText().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
if not widget_type or not shortcut:
print("Please select a registered widget and provide a shortcut.")
return
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
try:
self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title)
except Exception as exc:
print(f"Failed to add registered widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget:
module_path, _, class_name = class_path.rpartition(".")
if not module_path or not class_name:
raise ValueError("class_path must be of the form 'package.module.Class'")
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
if kwargs is None:
obj = cls()
else:
obj = cls(**kwargs)
if not isinstance(obj, QWidget):
raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}")
return obj
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget:
"""Add a QWidget as a new tab and expose it in the Jupyter console.
- widget: a QWidget instance to host in a new tab
- shortcut: variable name used in the console to access it
- title: optional tab title (defaults to shortcut or class name)
"""
if not isinstance(widget, QWidget):
raise TypeError("widget must be a QWidget instance")
if not shortcut or not shortcut.isidentifier():
raise ValueError("shortcut must be a valid Python identifier")
if shortcut in self._widgets_by_name:
raise ValueError(f"A widget with shortcut '{shortcut}' already exists")
if self.console.inprocess is not True:
raise RuntimeError("Adding widgets and exposing them requires inprocess console")
tab_title = title or shortcut or widget.__class__.__name__
self.tab_widget.addTab(widget, tab_title)
self._widgets_by_name[shortcut] = widget
# Expose in console under the given shortcut
self._push_to_console({shortcut: widget})
return widget
def add_widget_by_class_path(
self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
return self.add_widget(widget, shortcut, title=title)
def add_widget_by_type(
self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
"""Instantiate a registered BEC widget by its type string and add it as a tab.
If kwargs does not contain `object_name`, it will default to the provided shortcut.
"""
# Ensure registry is loaded
widget_handler.update_available_widgets()
cls = widget_handler.widget_classes.get(widget_type)
if cls is None:
raise ValueError(f"Unknown registered widget type: {widget_type}")
if kwargs is None:
kwargs = {"object_name": shortcut}
else:
kwargs = dict(kwargs)
kwargs.setdefault("object_name", shortcut)
# Instantiate and add
widget = cls(**kwargs)
if not isinstance(widget, QWidget):
raise TypeError(
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
)
return self.add_widget(widget, shortcut, title=title)
def _push_to_console(self, mapping: Dict[str, Any]):
"""Push Python objects into the inprocess kernel user namespace."""
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(mapping)
else:
raise RuntimeError("Can only push variables when using inprocess kernel")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
# clean up any widgets that might have custom cleanup
try:
# call cleanup on known containers if present
dock = self._widgets_by_name.get("dock")
if isinstance(dock, BECDockArea):
dock.cleanup()
dock.close()
except Exception:
pass
# Ensure the embedded kernel and BEC client are shut down before window teardown
self.console.shutdown_kernel()
self.dock.cleanup()
self.dock.close()
self.console.close()
super().closeEvent(event)
@@ -397,20 +176,13 @@ if __name__ == "__main__": # pragma: no cover
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
client = bec_dispatcher.client
client.start()
win = JupyterConsoleWindow()
# Examples: add two widgets programmatically to demonstrate usage
try:
win.add_widget_by_type("Waveform", shortcut="wf")
except Exception as exc:
print(f"Example add failed (Waveform by type): {exc}")
try:
win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True})
except Exception as exc:
print(f"Example add failed (Image by type): {exc}")
win.show()
win.resize(1500, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
def set_read_value(self, value):
self.read_value = value
def read(self, cached=False):
def read(self):
return self.signals
def set_limits(self, limits):

View File

@@ -129,17 +129,6 @@ class BECConnector:
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
try: # shutdown ophyd threads if any
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
logger.info("Ophyd dispatcher shut down successfully.")
except Exception as e:
logger.warning(
f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}"
)
logger.info("Shutting down BEC Client", repr(client))
client.shutdown()

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon, QPixmap
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
@@ -35,7 +35,7 @@ def designer_material_icon(icon_name: str) -> QIcon:
Returns:
QIcon: The QIcon for the material icon.
"""
return QIcon(material_icon(icon_name, filled=True, icon_type=QPixmap))
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
def list_editable_packages() -> set[str]:

View File

@@ -192,7 +192,6 @@ class BECWidget(BECConnector):
Returns:
str: The help text in markdown format.
"""
return ""
@SafeSlot()
@SafeSlot(str)

View File

@@ -3,7 +3,7 @@ from types import SimpleNamespace
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal
from qtpy.QtGui import QColor, QIcon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QDialog,
QHBoxLayout,
@@ -132,7 +132,7 @@ class CompactPopupWidget(QWidget):
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QToolButton(self.compact_view_widget)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), icon_type=QIcon)
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
self.compact_view_widget.layout().addWidget(self.compact_label)
self.compact_view_widget.layout().addWidget(self.compact_status)
@@ -171,7 +171,9 @@ class CompactPopupWidget(QWidget):
self.compact_label.setVisible(False)
self.compact_status.setVisible(False)
self.compact_show_popup.setIcon(
material_icon(icon_name="collapse_content", size=(10, 10), icon_type=QIcon)
material_icon(
icon_name="collapse_content", size=(10, 10), convert_to_pixmap=False
)
)
self.expand.emit(True)
else:
@@ -179,7 +181,9 @@ class CompactPopupWidget(QWidget):
self.compact_label.setVisible(True)
self.compact_status.setVisible(True)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), icon_type=QIcon)
material_icon(
icon_name="expand_content", size=(10, 10), convert_to_pixmap=False
)
)
self.compact_view = True
self.expand.emit(False)

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Signal
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -96,9 +95,11 @@ class ExpandableGroupFrame(QFrame):
def _update_expansion_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), icon_type=QIcon)
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
else material_icon(icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), icon_type=QIcon)
else material_icon(
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)
@SafeProperty(str)
@@ -114,7 +115,7 @@ class ExpandableGroupFrame(QFrame):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), icon_type=QPixmap)
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)

View File

@@ -7,7 +7,6 @@ from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -208,7 +207,7 @@ class PydanticModelForm(TypedForm):
self._validity.compact_view = True # type: ignore
self._validity.label = "Validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), icon_type=QIcon)
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)

View File

@@ -26,7 +26,7 @@ from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics, QIcon
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
@@ -203,7 +203,9 @@ class DynamicFormItem(QWidget):
def _add_clear_button(self):
self._clear_button = QToolButton()
self._clear_button.setIcon(material_icon(icon_name="close", size=(10, 10), icon_type=QIcon))
self._clear_button.setIcon(
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
)
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")

View File

@@ -1,735 +0,0 @@
"""Module providing a guided help system for creating interactive GUI tours."""
from __future__ import annotations
import sys
import weakref
from typing import Callable, Dict, List, TypedDict
from uuid import uuid4
import louie
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from louie import saferef
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
from qtpy.QtGui import QAction, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenuBar,
QPushButton,
QToolBar,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class TourStep(TypedDict):
"""Type definition for a tour step."""
widget_ref: (
louie.saferef.BoundMethodWeakref
| weakref.ReferenceType[
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
]
| Callable[[], tuple[QWidget | QAction, str | None]]
| None
)
text: str
title: str
class TutorialOverlay(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Keep mouse events enabled for the overlay but we'll handle them manually
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.current_rect = QRect()
self.message_box = self._create_message_box()
self.message_box.hide()
def _create_message_box(self):
box = QFrame(self)
app = QApplication.instance()
bg_color = app.palette().window().color()
box.setStyleSheet(
f"""
QFrame {{
background-color: {bg_color.name()};
border-radius: 8px;
padding: 8px;
}}
"""
)
layout = QVBoxLayout(box)
# Top layout with close button (left) and step indicator (right)
top_layout = QHBoxLayout()
# Close button on the left with material icon
self.close_btn = QPushButton()
self.close_btn.setIcon(material_icon("close"))
self.close_btn.setToolTip("Close")
self.close_btn.setMaximumSize(32, 32)
# Step indicator on the right
self.step_label = QLabel()
self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;")
top_layout.addWidget(self.close_btn)
top_layout.addStretch()
top_layout.addWidget(self.step_label)
# Main content label
self.label = QLabel()
self.label.setWordWrap(True)
# Bottom navigation buttons
btn_layout = QHBoxLayout()
# Back button with material icon
self.back_btn = QPushButton("Back")
self.back_btn.setIcon(material_icon("arrow_back"))
# Next button with material icon
self.next_btn = QPushButton("Next")
self.next_btn.setIcon(material_icon("arrow_forward"))
btn_layout.addStretch()
btn_layout.addWidget(self.back_btn)
btn_layout.addWidget(self.next_btn)
layout.addLayout(top_layout)
layout.addWidget(self.label)
layout.addLayout(btn_layout)
return box
def paintEvent(self, event): # pylint: disable=unused-argument
if not self.current_rect.isValid():
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Create semi-transparent overlay color
overlay_color = QColor(0, 0, 0, 160)
# Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive.
r = self.current_rect
rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height()
# Paint overlay in 4 regions around the highlighted widget using exclusive bounds
# Top region (everything above the highlight)
if rect_y > 0:
top_rect = QRect(0, 0, self.width(), rect_y)
painter.fillRect(top_rect, overlay_color)
# Bottom region (everything below the highlight)
bottom_y = rect_y + rect_h
if bottom_y < self.height():
bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y)
painter.fillRect(bottom_rect, overlay_color)
# Left region (to the left of the highlight)
if rect_x > 0:
left_rect = QRect(0, rect_y, rect_x, rect_h)
painter.fillRect(left_rect, overlay_color)
# Right region (to the right of the highlight)
right_x = rect_x + rect_w
if right_x < self.width():
right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h)
painter.fillRect(right_rect, overlay_color)
# Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap.
border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4)
painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(border_rect, 8, 8)
painter.end()
def show_step(
self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1
):
"""
rect must already be in the overlay's coordinate space (i.e. mapped).
This method positions the message box so it does not overlap the rect.
Args:
rect(QRect): rectangle to highlight
title(str): Title text for the step
text(str): Main content text for the step
current_step(int): Current step number
total_steps(int): Total number of steps in the tour
"""
self.current_rect = rect
# Update step indicator in top right
self.step_label.setText(f"Step {current_step} of {total_steps}")
# Update main content text (without step number since it's now in top right)
content_text = f"<b>{title}</b><br>{text}" if title else text
self.label.setText(content_text)
self.message_box.adjustSize() # ensure layout applied
message_size = self.message_box.size() # actual widget size (width, height)
spacing = 15
# Preferred placement: to the right, vertically centered
pos_x = rect.right() + spacing
pos_y = rect.center().y() - (message_size.height() // 2)
# If it would go off the right edge, try left of the widget
if pos_x + message_size.width() > self.width():
pos_x = rect.left() - message_size.width() - spacing
# vertical center is still good, but if that overlaps top/bottom we'll clamp below
# If it goes off the left edge (no space either side), place below, centered horizontally
if pos_x < spacing:
pos_x = rect.center().x() - (message_size.width() // 2)
pos_y = rect.bottom() + spacing
# If it goes off the bottom, try moving it above the widget
if pos_y + message_size.height() > self.height() - spacing:
# if there's room above the rect, put it there
candidate_y = rect.top() - message_size.height() - spacing
if candidate_y >= spacing:
pos_y = candidate_y
else:
# otherwise clamp to bottom with spacing
pos_y = max(spacing, self.height() - message_size.height() - spacing)
# If it goes off the top, clamp down
pos_y = max(spacing, pos_y)
# Make sure we don't poke the left edge
pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing))
# Apply geometry and show
self.message_box.setGeometry(
int(pos_x), int(pos_y), message_size.width(), message_size.height()
)
self.message_box.show()
self.update()
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Resize:
self.setGeometry(obj.rect())
return False
class GuidedTour(QObject):
"""
A guided help system for creating interactive GUI tours.
Allows developers to register widgets with help text and create guided tours.
"""
tour_started = Signal()
tour_finished = Signal()
step_changed = Signal(int, int) # current_step, total_steps
def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True):
super().__init__()
self._visible_check: bool = enforce_visibility
self.main_window_ref = saferef.safe_ref(main_window)
self.overlay = None
self._registered_widgets: Dict[str, TourStep] = {}
self._tour_steps: List[TourStep] = []
self._current_index = 0
self._active = False
@property
def main_window(self) -> QWidget | None:
"""Get the main window from weak reference."""
if self.main_window_ref and callable(self.main_window_ref):
widget = self.main_window_ref()
if isinstance(widget, QWidget):
return widget
return None
def register_widget(
self,
*,
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
text: str = "",
title: str = "",
) -> str:
"""
Register a widget with help text for tours.
Args:
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
text (str): The help text for the widget. This will be shown during the tour.
title (str, optional): A title for the widget (defaults to its class name or action text).
Returns:
str: The unique ID for the registered widget.
"""
step_id = str(uuid4())
# If it's a plain callable
if callable(widget) and not hasattr(widget, "__self__"):
widget_ref = widget
default_title = "Widget"
elif isinstance(widget, QAction):
widget_ref = weakref.ref(widget)
default_title = widget.text() or "Action"
elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button):
def _resolve_toolbar_button(toolbar_action=widget):
button = toolbar_action.get_toolbar_button()
return (button, None)
widget_ref = _resolve_toolbar_button
default_title = getattr(widget, "tooltip", "Toolbar Menu")
else:
widget_ref = saferef.safe_ref(widget)
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
self._registered_widgets[step_id] = {
"widget_ref": widget_ref,
"text": text,
"title": title or default_title,
}
logger.debug(f"Registered widget {title or default_title} with ID {step_id}")
return step_id
def _action_highlight_rect(self, action: QAction) -> QRect | None:
"""
Try to find the QRect in main_window coordinates that should be highlighted for the given QAction.
Returns None if not found (e.g. not visible).
"""
mw = self.main_window
if mw is None:
return None
# Try toolbars first
for tb in mw.findChildren(QToolBar):
btn = tb.widgetForAction(action)
if btn and btn.isVisible():
rect = btn.rect()
top_left = btn.mapTo(mw, rect.topLeft())
return QRect(top_left, rect.size())
# Try menu bars
menubars = []
if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)):
mb = mw.menuBar()
if mb and mb not in menubars:
menubars.append(mb)
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
for mb in menubars:
if action in mb.actions():
ar = mb.actionGeometry(action)
top_left = mb.mapTo(mw, ar.topLeft())
return QRect(top_left, ar.size())
return None
def unregister_widget(self, step_id: str) -> bool:
"""
Unregister a previously registered widget.
Args:
step_id (str): The unique ID of the registered widget.
Returns:
bool: True if the widget was unregistered, False if not found.
"""
if self._active:
raise RuntimeError("Cannot unregister widget while tour is active")
if step_id in self._registered_widgets:
if self._registered_widgets[step_id] in self._tour_steps:
self._tour_steps.remove(self._registered_widgets[step_id])
del self._registered_widgets[step_id]
return True
return False
def create_tour(self, step_ids: List[str] | None = None) -> bool:
"""
Create a tour from registered widget IDs.
Args:
step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included.
Returns:
bool: True if the tour was created successfully, False if any step IDs were not found
"""
if step_ids is None:
step_ids = list(self._registered_widgets.keys())
tour_steps = []
for step_id in step_ids:
if step_id not in self._registered_widgets:
logger.error(f"Step ID {step_id} not found")
return False
tour_steps.append(self._registered_widgets[step_id])
self._tour_steps = tour_steps
logger.info(f"Created tour with {len(tour_steps)} steps")
return True
@SafeSlot()
def start_tour(self):
"""Start the guided tour."""
if not self._tour_steps:
self.create_tour()
if self._active:
logger.warning("Tour already active")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
return
self._active = True
self._current_index = 0
# Create overlay
self.overlay = TutorialOverlay(main_window)
self.overlay.setGeometry(main_window.rect())
self.overlay.show()
main_window.installEventFilter(self.overlay)
# Connect signals
self.overlay.next_btn.clicked.connect(self.next_step)
self.overlay.back_btn.clicked.connect(self.prev_step)
self.overlay.close_btn.clicked.connect(self.stop_tour)
main_window.installEventFilter(self)
self._show_current_step()
self.tour_started.emit()
logger.info("Started guided tour")
@SafeSlot()
def stop_tour(self):
"""Stop the current tour."""
if not self._active:
return
self._active = False
main_window = self.main_window
if self.overlay and main_window:
main_window.removeEventFilter(self.overlay)
self.overlay.hide()
self.overlay.deleteLater()
self.overlay = None
if main_window:
main_window.removeEventFilter(self)
self.tour_finished.emit()
logger.info("Stopped guided tour")
@SafeSlot()
def next_step(self):
"""Move to next step or finish tour if on last step."""
if not self._active:
return
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
# On last step, finish the tour
self.stop_tour()
@SafeSlot()
def prev_step(self):
"""Move to previous step."""
if not self._active:
return
if self._current_index > 0:
self._current_index -= 1
self._show_current_step()
def _show_current_step(self):
"""Display the current step."""
if not self._active or not self.overlay:
return
step = self._tour_steps[self._current_index]
step_title = step["title"]
target, step_text = self._resolve_step_target(step)
if target is None:
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
self.stop_tour()
return
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
if highlight_rect is None:
return
# Calculate step numbers
current_step = self._current_index + 1
total_steps = len(self._tour_steps)
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
# Update button states
self.overlay.back_btn.setEnabled(self._current_index > 0)
# Update next button text and state
is_last_step = self._current_index >= len(self._tour_steps) - 1
if is_last_step:
self.overlay.next_btn.setText("Finish")
self.overlay.next_btn.setIcon(material_icon("check"))
self.overlay.next_btn.setEnabled(True)
else:
self.overlay.next_btn.setText("Next")
self.overlay.next_btn.setIcon(material_icon("arrow_forward"))
self.overlay.next_btn.setEnabled(True)
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
"""
Resolve the target widget/action for the given step.
Args:
step(TourStep): The tour step to resolve.
Returns:
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
"""
widget_ref = step.get("widget_ref")
step_text = step.get("text", "")
if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)):
target = widget_ref()
else:
target = widget_ref
if target is None:
return None, step_text
if callable(target) and not isinstance(target, (QWidget, QAction)):
result = target()
if isinstance(result, tuple):
target, alt_text = result
if alt_text:
step_text = alt_text
else:
target = result
return target, step_text
def _get_highlight_rect(
self, main_window: QWidget, target: QWidget | QAction, step_title: str
) -> QRect | None:
"""
Get the QRect in main_window coordinates to highlight for the given target.
Args:
main_window(QWidget): The main window containing the target.
target(QWidget | QAction): The target widget or action to highlight.
step_title(str): The title of the current step (for logging purposes).
Returns:
QRect | None: The rectangle to highlight, or None if not found/visible.
"""
if isinstance(target, QAction):
rect = self._action_highlight_rect(target)
if rect is None:
self._advance_past_invalid_step(
step_title,
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
)
return None
return rect
if isinstance(target, QWidget):
if self._visible_check:
if not target.isVisible():
self._advance_past_invalid_step(
step_title, reason=f"Widget {target!r} is not visible."
)
return None
rect = target.rect()
top_left = target.mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
self._advance_past_invalid_step(
step_title, reason=f"Unsupported step target type: {type(target)}"
)
return None
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
"""
Skip the current step (or stop the tour) when the target cannot be visualised.
"""
logger.warning("%s Skipping step %r.", reason, step_title)
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
self.stop_tour()
def get_registered_widgets(self) -> Dict[str, TourStep]:
"""Get all registered widgets."""
return self._registered_widgets.copy()
def clear_registrations(self):
"""Clear all registered widgets."""
if self._active:
self.stop_tour()
self._registered_widgets.clear()
self._tour_steps.clear()
logger.info("Cleared all registrations")
def set_visibility_enforcement(self, enabled: bool):
"""Enable or disable visibility checks when highlighting widgets."""
self._visible_check = enabled
def eventFilter(self, obj, event):
"""Handle window resize/move events to update step positioning."""
if event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
if self._active:
self._show_current_step()
return super().eventFilter(obj, event)
################################################################################
############ # Example usage of GuidedTour system ##############################
################################################################################
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Guided Tour Demo")
central = QWidget()
layout = QVBoxLayout(central)
layout.setSpacing(12)
layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support."))
self.btn1 = QPushButton("Primary Button")
self.btn2 = QPushButton("Secondary Button")
self.status_label = QLabel("Use the controls below or the toolbar to interact.")
self.start_tour_btn = QPushButton("Start Guided Tour")
layout.addWidget(self.btn1)
layout.addWidget(self.btn2)
layout.addWidget(self.status_label)
layout.addStretch()
layout.addWidget(self.start_tour_btn)
self.setCentralWidget(central)
# Guided tour system
self.guided_help = GuidedTour(self)
# Menus for demonstrating QAction support in menu bars
self._init_menu_bar()
# Modular toolbar showcasing QAction targets
self._init_toolbar()
# Register widgets and actions with help text
primary_step = self.guided_help.register_widget(
widget=self.btn1,
text="The primary button updates the status text when clicked.",
title="Primary Button",
)
secondary_step = self.guided_help.register_widget(
widget=self.btn2,
text="The secondary button complements the demo layout.",
title="Secondary Button",
)
toolbar_action_step = self.guided_help.register_widget(
widget=self.toolbar_tour_action.action,
text="Toolbar actions are supported in the guided tour. This one also starts the tour.",
title="Toolbar Tour Action",
)
tools_menu_step = self.guided_help.register_widget(
widget=self.toolbar.components.get_action("menu_tools"),
text="Expandable toolbar menus group related actions. This button opens the tools menu.",
title="Tools Menu",
)
# Create tour from registered widgets
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
widget_ids = self.tour_step_ids
self.guided_help.create_tour(widget_ids)
# Connect start button
self.start_tour_btn.clicked.connect(self.guided_help.start_tour)
def _init_menu_bar(self):
menu_bar = self.menuBar()
info_menu = menu_bar.addMenu("Info")
info_menu.setObjectName("info-menu")
self.info_menu = info_menu
self.info_menu_action = info_menu.menuAction()
self.about_action = info_menu.addAction("About This Demo")
def _init_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
self.toolbar_tour_action = MaterialIconAction(
"play_circle", tooltip="Start the guided tour", parent=self
)
self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action)
self.toolbar_highlight_action = MaterialIconAction(
"visibility", tooltip="Highlight the primary button", parent=self
)
self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action)
demo_bundle = self.toolbar.new_bundle("demo")
demo_bundle.add_action("tour-start")
demo_bundle.add_action("inspect-primary")
self._setup_tools_menu()
self.toolbar.show_bundles(["demo", "menu_tools"])
self.toolbar.refresh()
self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour)
def _setup_tools_menu(self):
self.tools_menu_actions: dict[str, MaterialIconAction] = {
"notes": MaterialIconAction(
icon_name="note_add", tooltip="Add a note", filled=True, parent=self
),
"bookmark": MaterialIconAction(
icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self
),
"settings": MaterialIconAction(
icon_name="tune", tooltip="Adjust settings", filled=True, parent=self
),
}
self.tools_menu_action = ExpandableMenuAction(
label="Tools ", actions=self.tools_menu_actions
)
self.toolbar.components.add_safe("menu_tools", self.tools_menu_action)
bundle = ToolbarBundle("menu_tools", self.toolbar.components)
bundle.add_action("menu_tools")
self.toolbar.add_bundle(bundle)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
from bec_qthemes import apply_theme
apply_theme("dark")
w = MainWindow()
w.resize(400, 300)
w.show()
sys.exit(app.exec())

View File

@@ -11,7 +11,6 @@ from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
@@ -101,7 +100,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
self._button.setChecked(False)
QtWidgets.QApplication.restoreOverrideCursor()
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
def eventFilter(self, obj, event):
"""
Filter events to capture Key_Escape event, and mouse clicks
if event filter is active. Any click event on a widget is suppressed, if
@@ -112,33 +111,25 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
obj (QObject): The object that received the event.
event (QEvent): The event to filter.
"""
# If not active, return immediately
if not self._active:
return super().eventFilter(obj, event)
# If active, handle escape key
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
if (
event.type() == QtCore.QEvent.KeyPress
and event.key() == QtCore.Qt.Key_Escape
and self._active
):
self._toggle_mode(False)
return super().eventFilter(obj, event)
# If active, and left mouse button pressed, handle click
if event.type() == QtCore.QEvent.MouseButtonPress:
if self._active and event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
widget = self._app.widgetAt(event.globalPos())
if widget is None:
return super().eventFilter(obj, event)
# Get BECWidget ancestor
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
if not isinstance(widget, BECWidget):
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
if widget:
if widget is self:
if widget is self or self.isAncestorOf(widget):
self._toggle_mode(False)
return True
for cb in self._callbacks.values():
try:
cb(widget)
except Exception as e:
logger.error(f"Error occurred in callback {cb}: {e}")
print(f"Error occurred in callback {cb}: {e}")
return True
return super().eventFilter(obj, event)

View File

@@ -1,25 +1,34 @@
from typing import Type
from bec_lib.codecs import BECCodec
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
class QPointFEncoder(BECCodec):
obj_type = QPointF
@staticmethod
def encode(obj: QPointF) -> list[float]:
"""Encode a QPointF object to a list of floats."""
return [obj.x(), obj.y()]
@staticmethod
def decode(type_name: str, data: list[float]) -> list[float]:
"""No-op function since QPointF is encoded as a list of floats."""
return data
def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not msgpack.is_registered(QPointF):
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
msgpack.register_codec(QPointFEncoder)
class QPointFEncoder(BECCodec):
obj_type: Type = QPointF
@staticmethod
def encode(obj: QPointF) -> str:
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
@staticmethod
def decode(type_name: str, data: list[float]) -> list[float]:
"""
no-op function since QPointF is encoded as a list of floats.
"""
return data

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import os
import weakref
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
@@ -44,18 +43,12 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar):
"""
btn = QToolButton(parent=toolbar)
if getattr(toolbar_action, "label_text", None):
toolbar_action.action.setText(toolbar_action.label_text)
if getattr(toolbar_action, "tooltip", None):
toolbar_action.action.setToolTip(toolbar_action.tooltip)
btn.setToolTip(toolbar_action.tooltip)
btn.setDefaultAction(toolbar_action.action)
btn.setAutoRaise(True)
if toolbar_action.text_position == "beside":
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
else:
if toolbar_action.text_position == "under":
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
else:
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
btn.setText(toolbar_action.label_text)
toolbar.addWidget(btn)
@@ -218,7 +211,11 @@ class MaterialIconAction(ToolBarAction):
self.text_position = text_position
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name, size=(20, 20), icon_type=QIcon, filled=self.filled, color=self.color
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
if parent is None:
logger.warning(
@@ -503,8 +500,6 @@ class ExpandableMenuAction(ToolBarAction):
def __init__(self, label: str, actions: dict, icon_path: str = None):
super().__init__(icon_path, label)
self.actions = actions
self._button_ref: weakref.ReferenceType[QToolButton] | None = None
self._menu_ref: weakref.ReferenceType[QMenu] | None = None
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
@@ -540,14 +535,6 @@ class ExpandableMenuAction(ToolBarAction):
menu.addAction(action)
button.setMenu(menu)
toolbar.addWidget(button)
self._button_ref = weakref.ref(button)
self._menu_ref = weakref.ref(menu)
def get_toolbar_button(self) -> QToolButton | None:
return self._button_ref() if self._button_ref else None
def get_menu(self) -> QMenu | None:
return self._menu_ref() if self._menu_ref else None
class DeviceComboBoxAction(WidgetAction):
@@ -594,76 +581,3 @@ class DeviceComboBoxAction(WidgetAction):
self.combobox.close()
self.combobox.deleteLater()
return super().cleanup()
class TutorialAction(MaterialIconAction):
"""
Action for starting a guided tutorial/help tour.
This action automatically initializes a GuidedTour instance and provides
methods to register widgets and start tours.
Args:
main_window (QWidget): The main window widget for the guided tour overlay.
tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial".
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None):
super().__init__(
icon_name="help",
tooltip=tooltip,
checkable=False,
filled=False,
color=None,
parent=parent,
)
from bec_widgets.utils.guided_tour import GuidedTour
self.guided_help = GuidedTour(main_window)
self.main_window = main_window
# Connect the action to start the tour
self.action.triggered.connect(self.start_tour)
def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str:
"""
Register a widget for the guided tour.
Args:
widget (QWidget): The widget to highlight during the tour.
text (str): The help text to display.
widget_name (str, optional): Optional name for the widget.
Returns:
str: Unique ID for the registered widget.
"""
return self.guided_help.register_widget(widget, text, widget_name)
def start_tour(self):
"""Start the guided tour with all registered widgets."""
registered_widgets = self.guided_help.get_registered_widgets()
if registered_widgets:
# Create tour from all registered widgets
step_ids = list(registered_widgets.keys())
if self.guided_help.create_tour(step_ids):
self.guided_help.start_tour()
else:
logger.warning("Failed to create guided tour")
else:
logger.warning("No widgets registered for guided tour")
def has_registered_widgets(self) -> bool:
"""Check if any widgets have been registered for the tour."""
return len(self.guided_help.get_registered_widgets()) > 0
def clear_registered_widgets(self):
"""Clear all registered widgets."""
self.guided_help.clear_registrations()
def cleanup(self):
"""Clean up the guided help instance."""
if hasattr(self, "guided_help"):
self.guided_help.stop_tour()
super().cleanup()

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
@@ -492,33 +492,10 @@ if __name__ == "__main__": # pragma: no cover
self.toolbar.connect_bundle(
"base", PerformanceConnection(self.toolbar.components, self)
)
self.toolbar.components.add_safe(
"text",
MaterialIconAction(
"text_fields",
tooltip="Test Text Action",
checkable=True,
label_text="text",
text_position="under",
),
)
self.toolbar.show_bundles(["performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.get_bundle("performance").add_action("text")
self.toolbar.refresh()
# Timer to disable and enable text button each 2s
self.timer = QTimer()
self.timer.timeout.connect(self.toggle_text_action)
self.timer.start(2000)
def toggle_text_action(self):
text_action = self.toolbar.components.get_action("text")
if text_action.action.isEnabled():
text_action.action.setEnabled(False)
else:
text_action.action.setEnabled(True)
def enable_fps_monitor(self, enabled: bool):
"""
Example method to enable or disable FPS monitoring.

View File

@@ -1 +0,0 @@
from PySide6QtAds import *

File diff suppressed because it is too large Load Diff

View File

@@ -50,9 +50,9 @@ from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.worksp
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
@@ -318,22 +318,16 @@ class AdvancedDockArea(BECWidget, QWidget):
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
"positioner_box_2D": (
PositionerBox2D.ICON_NAME,
"Add Device 2D Box",
"PositionerBox2D",
),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
@@ -450,18 +444,6 @@ class AdvancedDockArea(BECWidget, QWidget):
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
)
elif key == "bec_shell":
act.triggered.connect(
lambda _, t=widget_type: self.new(
widget=t,
closable=True,
startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}",
)
)
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
@@ -536,7 +518,6 @@ class AdvancedDockArea(BECWidget, QWidget):
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
**kwargs,
) -> BECWidget:
"""
Create a new widget (or reuse an instance) and add it as a dock.
@@ -549,7 +530,6 @@ class AdvancedDockArea(BECWidget, QWidget):
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
**kwargs: The keyword arguments for the widget.
Returns:
The widget instance.
"""
@@ -557,9 +537,7 @@ class AdvancedDockArea(BECWidget, QWidget):
# 1) Instantiate or look up the widget
if isinstance(widget, str):
widget = cast(
BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs)
)
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
from bec_widgets import SafeSlot
@@ -29,7 +28,7 @@ class ProfileComboBox(QComboBox):
self.blockSignals(True)
self.clear()
lock_icon = material_icon("edit_off", size=(16, 16), icon_type=QIcon)
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
for profile in list_profiles():
if is_profile_readonly(profile):
@@ -169,7 +168,9 @@ class WorkspaceConnection(BundleConnection):
"""
setattr(self.target_widget, "lock_workspace", value)
self.components.get_action("lock").action.setChecked(value)
icon = material_icon("lock" if value else "lock_open_right", size=(20, 20), icon_type=QIcon)
icon = material_icon(
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.components.get_action("lock").action.setIcon(icon)
@SafeSlot()

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag, QIcon
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
@@ -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
@@ -66,16 +57,13 @@ class CollapsibleSection(QWidget):
header_layout.addWidget(self.header_button)
header_layout.addStretch()
# Add button in header (icon-only)
self.header_add_button = QToolButton()
self.header_add_button = QPushButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(28, 28)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.header_add_button.setAutoRaise(True)
self.header_add_button.setIcon(material_icon("add", size=(28, 28)))
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
@@ -100,7 +88,7 @@ class CollapsibleSection(QWidget):
"""Update the header button appearance based on expanded state"""
# Use material icons with consistent sizing to match tree items
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
icon = material_icon(icon_name=icon_name, size=(20, 20), icon_type=QIcon)
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
self.header_button.setIcon(icon)
self.header_button.setText(self.title)

View File

@@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget):
RPC = False
PLUGIN = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
self.main_layout = QVBoxLayout(self)

View File

@@ -1,125 +0,0 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView
from bec_widgets.utils.colors import get_theme_palette
class ExplorerDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover for the explorer"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QSortFilterProxyModel
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
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, self.target_model):
return
actions = self.get_actions_for_current_item(proxy_model, index)
if actions:
self._draw_action_buttons(painter, option, 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 get_actions_for_current_item(self, model, index) -> list[QAction] | None:
"""Get actions for the current item based on its type"""
return None
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)
actions = self.get_actions_for_current_item(model, index)
if not actions:
return super().editorEvent(event, model, option, index)
# Check which button was clicked
visible_actions = [action for action in 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

View File

@@ -1,382 +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 QStandardItem, QStandardItemModel
from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class MacroItemDelegate(ExplorerDelegate):
"""Custom delegate to show action buttons on hover for macro functions"""
def __init__(self, parent=None):
super().__init__(parent)
self.macro_actions: list[Any] = []
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QStandardItemModel
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 get_actions_for_current_item(self, model, index) -> list[QAction] | None:
# 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
return self.macro_actions
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()

View File

@@ -2,29 +2,32 @@ import os
from pathlib import Path
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal
from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
from qtpy.QtGui import QAction, QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class FileItemDelegate(ExplorerDelegate):
class FileItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, tree_widget):
super().__init__(tree_widget)
self.file_actions = []
self.dir_actions = []
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
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)
@@ -33,18 +36,126 @@ class FileItemDelegate(ExplorerDelegate):
self.file_actions.clear()
self.dir_actions.clear()
def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None:
"""Get actions for the current item based on its type"""
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
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, QSortFilterProxyModel):
return
source_index = proxy_model.mapToSource(index)
source_model = proxy_model.sourceModel()
if not isinstance(source_model, QFileSystemModel):
return
is_dir = source_model.isDir(source_index)
file_path = source_model.filePath(source_index)
self.current_file_path = file_path
# Choose appropriate actions based on item type
actions = self.dir_actions if is_dir else self.file_actions
if actions:
self._draw_action_buttons(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
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)
# Early return if not a proxy model
if not isinstance(model, QSortFilterProxyModel):
return None
return super().editorEvent(event, model, option, index)
source_index = model.mapToSource(index)
source_model = model.sourceModel()
# Early return if not a file system model
if not isinstance(source_model, QFileSystemModel):
return None
return super().editorEvent(event, model, option, index)
is_dir = source_model.isDir(source_index)
return self.dir_actions if is_dir else self.file_actions
actions = self.dir_actions if is_dir else self.file_actions
# Check which button was clicked
visible_actions = [action for action in 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 ScriptTreeWidget(QWidget):
@@ -118,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;
@@ -181,14 +286,14 @@ class ScriptTreeWidget(QWidget):
return super().eventFilter(obj, event)
def set_directory(self, directory: str) -> None:
def set_directory(self, directory):
"""Set the scripts directory"""
# Early return if directory doesn't exist
if not directory or not isinstance(directory, str) or not os.path.exists(directory):
return
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
@@ -252,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)

View File

@@ -119,7 +119,7 @@ class NotificationToast(QFrame):
color=SEVERITY[self._kind.value]["color"],
filled=True,
size=(24, 24),
icon_type=QtGui.QIcon,
convert_to_pixmap=False,
)
)
icon_btn.setIconSize(QtCore.QSize(24, 24))
@@ -901,7 +901,7 @@ class NotificationIndicator(QWidget):
color=SEVERITY[sev.value]["color"],
filled=True,
size=(20, 20),
icon_type=QIcon,
convert_to_pixmap=False,
)
b.setIcon(icon)
b.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)

View File

@@ -1,6 +1,5 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -24,7 +23,9 @@ class ResetButton(BECWidget, QWidget):
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("restart_alt", color="#F19E39", filled=True, icon_type=QIcon)
icon = material_icon(
"restart_alt", color="#F19E39", filled=True, convert_to_pixmap=False
)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Reset the scan queue")
else:

View File

@@ -1,6 +1,5 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -25,7 +24,7 @@ class ResumeButton(BECWidget, QWidget):
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("resume", color="#2793e8", filled=True, icon_type=QIcon)
icon = material_icon("resume", color="#2793e8", filled=True, convert_to_pixmap=False)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Resume the scan queue")
else:

View File

@@ -1,6 +1,5 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -25,7 +24,7 @@ class StopButton(BECWidget, QWidget):
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("stop", color="#cc181e", filled=True, icon_type=QIcon)
icon = material_icon("stop", color="#cc181e", filled=True, convert_to_pixmap=False)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Stop the scan queue")
else:

View File

@@ -88,7 +88,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if not self._check_device_is_valid(device):
return
data = self.dev[device].read(cached=True)
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),

View File

@@ -8,7 +8,7 @@ from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator, QIcon
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
@@ -87,7 +87,7 @@ class PositionerBox(PositionerBoxBase):
self.ui.setpoint.setValidator(self.setpoint_validator)
self.ui.spinner_widget.start()
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
icon = material_icon(icon_name="edit_note", size=(16, 16), icon_type=QIcon)
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button.setIcon(icon)
def force_update_readback(self):

View File

@@ -9,7 +9,7 @@ from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator, QIcon
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
@@ -121,7 +121,7 @@ class PositionerBox2D(PositionerBoxBase):
self.ui.tool_button_ver.clicked.connect(
self._open_dialog_selection(self.set_positioner_ver)
)
icon = material_icon(icon_name="edit_note", size=(16, 16), icon_type=QIcon)
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button_hor.setIcon(icon)
self.ui.tool_button_ver.setIcon(icon)

View File

@@ -6,67 +6,3 @@ MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config"
# Custom user roles
SORT_KEY_ROLE: Final[int] = 117
CONFIG_DATA_ROLE: Final[int] = 118
# TODO 882 keep in sync with headers in device_table_view.py
HEADERS_HELP_MD: dict[str, str] = {
"status": "\n".join(
[
"## Status",
"The current status of the device. Can be one of the following values: ",
"### **LOADED** \n The device with the specified configuration is loaded in the current config.",
"### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.",
"### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.",
"### **VALID** \n The device config is valid, but the connection has not yet been validated.",
"### **INVALID** \n The device config is invalid and can not be loaded to the current config.",
]
),
"name": "\n".join(["## Name ", "The name of the device."]),
"deviceClass": "\n".join(
[
"## Device Class",
"The device class specifies the type of the device. It will be used to create the instance.",
]
),
"readoutPriority": "\n".join(
[
"## Readout Priority",
"The readout priority of the device. Can be one of the following values: ",
"### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.",
"### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.",
"### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.",
"### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.",
"### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.",
]
),
"deviceTags": "\n".join(
[
"## Device Tags",
"A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.",
]
),
"enabled": "\n".join(
[
"## Enabled",
"Indicator whether the device is enabled or disabled. Disabled devices can not be used.",
]
),
"readOnly": "\n".join(
["## Read Only", "Indicator that a device is read-only or can be modified."]
),
"onFailure": "\n".join(
[
"## On Failure",
"Specifies the behavior of the device in case of a failure. Can be one of the following values: ",
"### **buffer** \n The device readback will fall back to the last known value.",
"### **retry** \n The device readback will be retried once, and raises an error if it fails again.",
"### **raise** \n The device readback will raise immediately.",
]
),
"softwareTrigger": "\n".join(
[
"## Software Trigger",
"Indicator whether the device receives a software trigger from BEC during a scan.",
]
),
"description": "\n".join(["## Description", "A short description of the device."]),
}

View File

@@ -4,13 +4,11 @@ from __future__ import annotations
import copy
import json
import textwrap
from contextlib import contextmanager
from functools import partial
from typing import TYPE_CHECKING, Any, Iterable, List, Literal
from typing import TYPE_CHECKING, Any, Iterable, List
from uuid import uuid4
from bec_lib.atlas_models import Device
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
@@ -23,10 +21,7 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.constants import (
HEADERS_HELP_MD,
MIME_DEVICE_CONFIG,
)
from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
if TYPE_CHECKING: # pragma: no cover
@@ -46,13 +41,7 @@ USER_CHECK_DATA_ROLE = 101
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
def helpEvent(
self,
event: QtCore.QEvent,
view: QtWidgets.QAbstractItemView,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
):
def helpEvent(self, event, view, option, index):
"""Override to show tooltip when hovering."""
if event.type() != QtCore.QEvent.Type.ToolTip:
return super().helpEvent(event, view, option, index)
@@ -70,23 +59,13 @@ class CustomDisplayDelegate(DictToolTipDelegate):
def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str:
return ""
def _test_custom_paint(
self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex
):
def _test_custom_paint(self, painter, option, index):
v = index.model().data(index, self._paint_test_role)
return (v is not None), v
def _do_custom_paint(
self,
painter: QtGui.QPainter,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
value: Any,
): ...
def _do_custom_paint(self, painter, option, index, value): ...
def paint(
self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex
) -> None:
def paint(self, painter, option, index) -> None:
(check, value) = self._test_custom_paint(painter, option, index)
if not check:
return super().paint(painter, option, index)
@@ -96,150 +75,12 @@ class CustomDisplayDelegate(DictToolTipDelegate):
painter.restore()
class WrappingTextDelegate(CustomDisplayDelegate):
"""A lightweight delegate that wraps text without expensive size recalculation."""
def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6):
super().__init__(parent)
self._parent = parent
self.max_width = max_width
self.margin = margin
self._cache = {} # cache text metrics for performance
self._wrapping_text_columns = None
@property
def wrapping_text_columns(self) -> List[int]:
# Compute once, cache for later
if self._wrapping_text_columns is None:
self._wrapping_text_columns = []
view = self._parent
proxy: DeviceFilterProxyModel = self._parent.model()
for col in range(proxy.columnCount()):
delegate = view.itemDelegateForColumn(col)
if isinstance(delegate, WrappingTextDelegate):
self._wrapping_text_columns.append(col)
return self._wrapping_text_columns
def _do_custom_paint(
self,
painter: QtGui.QPainter,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
value: str,
):
text = str(value)
if not text:
return
painter.save()
painter.setClipRect(option.rect)
# Use cached layout if available
cache_key = (text, option.rect.width())
layout = self._cache.get(cache_key)
if layout is None:
layout = self._compute_layout(text, option)
self._cache[cache_key] = layout
# Draw text
painter.setPen(option.palette.text().color())
layout.draw(painter, option.rect.topLeft())
painter.restore()
def _compute_layout(
self, text: str, option: QtWidgets.QStyleOptionViewItem
) -> QtGui.QTextLayout:
"""Compute and return the text layout for given text and option."""
layout = self._get_layout(text, option.font)
text_option = QtGui.QTextOption()
text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere)
layout.setTextOption(text_option)
layout.beginLayout()
height = 0
max_lines = 100 # safety cap, should never be more than 100 lines..
for _ in range(max_lines):
line = layout.createLine()
if not line.isValid():
break
line.setLineWidth(option.rect.width() - self.margin)
line.setPosition(QtCore.QPointF(self.margin / 2, height))
line_height = line.height()
if line_height <= 0:
break # avoid negative or zero height lines to be added
height += line_height
layout.endLayout()
return layout
def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout:
return QtGui.QTextLayout(text, font_option)
def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize:
"""Return a cached or approximate height; avoids costly recomputation."""
text = str(index.data(QtCore.Qt.DisplayRole) or "")
view = self._parent
view.initViewItemOption(option)
if view.isColumnHidden(index.column()) or not view.isVisible() or not text:
return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin)
# Use cache for consistent size computation
cache_key = (text, self.max_width)
if cache_key in self._cache:
layout = self._cache[cache_key]
height = 0
for i in range(layout.lineCount()):
height += layout.lineAt(i).height()
return QtCore.QSize(self.max_width, int(height + self.margin))
# Approximate without layout (fast path)
metrics = option.fontMetrics
pixel_width = max(self._parent.columnWidth(index.column()), 100)
if pixel_width > 2000: # safeguard against uninitialized columns, may return large values
pixel_width = 100
char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin)
wrapped_lines = textwrap.wrap(text, width=char_per_line)
lines = len(wrapped_lines)
return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin)
def estimate_chars_per_line(
self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int
) -> int:
"""Estimate number of characters that fit in a line for given width."""
metrics = option.fontMetrics
elided = metrics.elidedText(text, Qt.ElideRight, column_width)
return len(elided.rstrip(""))
@SafeSlot(int, int, int)
@SafeSlot(int)
def _on_section_resized(
self, logical_index: int, old_size: int | None = None, new_size: int | None = None
):
"""Only update rows if a wrapped column was resized."""
self._cache.clear()
# Make sure layout is computed first
QtCore.QTimer.singleShot(0, self._update_row_heights)
def _update_row_heights(self):
"""Efficiently adjust row heights based on wrapped columns."""
view = self._parent
proxy = view.model()
option = QtWidgets.QStyleOptionViewItem()
view.initViewItemOption(option)
for row in range(proxy.rowCount()):
max_height = 18
for column in self.wrapping_text_columns:
index = proxy.index(row, column)
delegate = view.itemDelegateForColumn(column)
hint = delegate.sizeHint(option, index)
max_height = max(max_height, hint.height())
if view.rowHeight(row) != max_height:
view.setRowHeight(row, max_height)
class CenterCheckBoxDelegate(CustomDisplayDelegate):
"""Custom checkbox delegate to center checkboxes in table cells."""
_paint_test_role = USER_CHECK_DATA_ROLE
def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None):
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)
@@ -248,31 +89,16 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate):
def apply_theme(self, theme: str | None = None):
colors = get_accent_colors()
_icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True)
self._icon_checked = _icon("check_box")
self._icon_unchecked = _icon("check_box_outline_blank")
self._icon_checked.setColor(colors.default)
self._icon_unchecked.setColor(colors.default)
def _do_custom_paint(
self,
painter: QtGui.QPainter,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
value: Literal[
Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked
],
):
def _do_custom_paint(self, painter, option, index, value):
pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked
pix_rect = pixmap.rect()
pix_rect.moveCenter(option.rect.center())
painter.drawPixmap(pix_rect.topLeft(), pixmap)
def editorEvent(
self,
event: QtCore.QEvent,
model: QtCore.QSortFilterProxyModel,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
):
def editorEvent(self, event, model, option, index):
if event.type() != QtCore.QEvent.Type.MouseButtonRelease:
return False
current = model.data(index, USER_CHECK_DATA_ROLE)
@@ -285,7 +111,7 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate):
class DeviceValidatedDelegate(CustomDisplayDelegate):
"""Custom delegate for displaying validated device configurations."""
def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None):
def __init__(self, parent=None, colors=None):
super().__init__(parent)
colors = colors if colors else get_accent_colors()
_icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True)
@@ -297,30 +123,10 @@ class DeviceValidatedDelegate(CustomDisplayDelegate):
def apply_theme(self, theme: str | None = None):
colors = 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),
}
for status, icon in self._icons.items():
icon.setColor(colors[status])
def _do_custom_paint(
self,
painter: QtGui.QPainter,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
value: Literal[0, 1, 2],
):
"""
Paint the validation status icon centered in the cell.
Args:
painter (QtGui.QPainter): The painter object.
option (QtWidgets.QStyleOptionViewItem): The style options for the item.
index (QModelIndex): The model index of the item.
value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed.
Relates to ValidationStatus enum.
"""
def _do_custom_paint(self, painter, option, index, value):
if pixmap := self._icons.get(value):
pix_rect = pixmap.rect()
pix_rect.moveCenter(option.rect.center())
@@ -337,25 +143,20 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
# tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed
configs_changed = QtCore.Signal(list, bool)
def __init__(self, parent: DeviceTableModel | None = None):
def __init__(self, parent=None):
super().__init__(parent)
self._device_config: list[dict[str, Any]] = []
self._validation_status: dict[str, ValidationStatus] = {}
# TODO 882 keep in sync with HEADERS_HELP_MD
self.headers = [
"status",
"",
"name",
"deviceClass",
"readoutPriority",
"onFailure",
"deviceTags",
"description",
"enabled",
"readOnly",
"softwareTrigger",
]
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
self._device_model_schema = Device.model_json_schema()
###############################################
########## Override custom Qt methods #########
@@ -371,8 +172,6 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
if section == 9: # softwareTrigger
return "softTrig"
return self.headers[section]
return None
@@ -393,24 +192,20 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
return self._validation_status.get(dev_name, ValidationStatus.PENDING)
key = self.headers[col]
value = self._device_config[row].get(key, None)
if value is None:
value = (
self._device_model_schema.get("properties", {}).get(key, {}).get("default", None)
)
value = self._device_config[row].get(key)
if role == Qt.ItemDataRole.DisplayRole:
if key in ("enabled", "readOnly", "softwareTrigger"):
if key in ("enabled", "readOnly"):
return bool(value)
if key == "deviceTags":
return ", ".join(str(tag) for tag in value) if value else ""
if key == "deviceClass":
return str(value).split(".")[-1]
return str(value) if value is not None else ""
if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"):
if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly"):
return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked
if role == Qt.ItemDataRole.TextAlignmentRole:
if key in ("enabled", "readOnly", "softwareTrigger"):
if key in ("enabled", "readOnly"):
return Qt.AlignmentFlag.AlignCenter
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
if role == Qt.ItemDataRole.FontRole:
@@ -428,7 +223,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled
)
if key in ("enabled", "readOnly", "softwareTrigger"):
if key in ("enabled", "readOnly"):
if self._checkable_columns_enabled.get(key, True):
return base_flags | Qt.ItemFlag.ItemIsUserCheckable
else:
@@ -450,7 +245,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
if not index.isValid():
return False
key = self.headers[index.column()]
if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE:
if key in ("enabled", "readOnly") and role == USER_CHECK_DATA_ROLE:
if not self._checkable_columns_enabled.get(key, True):
return False # ignore changes if column is disabled
self._device_config[index.row()][key] = value == Qt.CheckState.Checked
@@ -504,8 +299,9 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
added_configs = []
for cfg in device_configs:
if self._name_exists_in_config(name := cfg.get("name", "<not found>"), True):
logger.warning(f"Device {name} is already in the config. It will be updated.")
self.remove_configs_by_name([name])
logger.warning(f"Device {name} already exists in the model.")
already_in_list.append(name)
continue
row = len(self._device_config)
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self._device_config.append(copy.deepcopy(cfg))
@@ -666,24 +462,7 @@ class BECTableView(QtWidgets.QTableView):
"""
configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())]
names = [cfg.get("name", "<unknown>") for cfg in configs]
if not names:
logger.warning("No device names found for selected rows.")
return False
if self._remove_rows_msg_dialog(names):
model.remove_device_configs(configs)
return True
return False
def _remove_rows_msg_dialog(self, names: list[str]) -> bool:
"""
Prompt the user to confirm removal of rows and remove them from the model if accepted.
Args:
names (list[str]): List of device names to be removed.
Returns:
bool: True if the user confirmed removal, False otherwise.
"""
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Warning)
msg.setWindowTitle("Confirm device removal")
@@ -697,6 +476,7 @@ class BECTableView(QtWidgets.QTableView):
res = msg.exec_()
if res == QMessageBox.StandardButton.Ok:
model.remove_device_configs(configs)
return True
return False
@@ -708,12 +488,7 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
self._hidden_rows = set()
self._filter_text = ""
self._enable_fuzzy = True
self._filter_columns = [1, 2, 6] # name, deviceClass and description for search
self._status_order = {
ValidationStatus.VALID: 0,
ValidationStatus.PENDING: 1,
ValidationStatus.FAILED: 2,
}
self._filter_columns = [1, 2] # name and deviceClass for search
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]:
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
@@ -731,14 +506,6 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
self._hidden_rows.update(row_indices)
self.invalidateFilter()
def lessThan(self, left, right):
"""Add custom sorting for the status column"""
if left.column() != 0 or right.column() != 0:
return super().lessThan(left, right)
left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole)
right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole)
return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99)
def show_rows(self, row_indices: list[int]):
"""
Show specific rows in the model.
@@ -835,21 +602,6 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
# Connect signals
self._model.configs_changed.connect(self.device_configs_changed.emit)
def get_help_md(self) -> str:
"""
Generate Markdown help for a cell or header.
"""
pos = self.table.mapFromGlobal(QtGui.QCursor.pos())
model: DeviceTableModel = self._model # access underlying model
index = self.table.indexAt(pos)
if index.isValid():
column = index.column()
label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole)
if label == "softTrig":
label = "softwareTrigger"
return HEADERS_HELP_MD.get(label, "")
return ""
def _setup_search(self):
"""Create components related to the search functionality"""
@@ -901,20 +653,15 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors)
self.tool_tip_delegate = DictToolTipDelegate(self.table)
self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors)
self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300)
# Add resize handling for wrapped delegate
header = self.table.horizontalHeader()
self.table.setItemDelegateForColumn(0, self.validated_delegate) # status
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) # onFailure
self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags
self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description
self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly
self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger
self.table.setItemDelegateForColumn(
4, self.tool_tip_delegate
) # deviceTags (was wrap_delegate)
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)
@@ -928,35 +675,19 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
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.Interactive) # onFailure
header.setSectionResizeMode(
5, QHeaderView.ResizeMode.Interactive
) # deviceTags: expand to fill
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill
header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled
header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly
header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags: expand to fill
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly
self.table.setColumnWidth(0, 70)
self.table.setColumnWidth(5, 200)
self.table.setColumnWidth(6, 200)
self.table.setColumnWidth(7, 70)
self.table.setColumnWidth(8, 70)
self.table.setColumnWidth(9, 70)
self.table.setColumnWidth(0, 25)
self.table.setColumnWidth(5, 70)
self.table.setColumnWidth(6, 70)
# Ensure column widths stay fixed
header.setMinimumSectionSize(25)
header.setDefaultSectionSize(90)
header.setStretchLastSection(False)
# Resize policy for wrapped text delegate
self._resize_proxy = BECSignalProxy(
header.sectionResized,
rateLimit=25,
slot=self.wrapped_delegate._on_section_resized,
timeout=1.0,
)
# Selection behavior
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
@@ -966,7 +697,6 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
# Connect model signals to autosize request
self._model.rowsInserted.connect(self._request_autosize_columns)
self._model.rowsRemoved.connect(self._request_autosize_columns)
self._model.modelReset.connect(self._request_autosize_columns)
self._model.dataChanged.connect(self._request_autosize_columns)
@@ -1109,21 +839,8 @@ if __name__ == "__main__":
button.clicked.connect(_button_clicked)
# pylint: disable=protected-access
config = window.client.device_manager._get_redis_device_config()
config.insert(
0,
{
"name": "TestDevice",
"deviceClass": "bec.devices.MockDevice",
"description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.",
"deviceTags": ["test", "mock", "longtagnameexample"],
"enabled": True,
"readOnly": False,
"softwareTrigger": True,
},
)
# names = [cfg.pop("name") for cfg in config]
# config_dict = {name: cfg for name, cfg in zip(names, config)}
window.set_device_config(config)
window.resize(1920, 1200)
widget.show()
sys.exit(app.exec_())

View File

@@ -43,7 +43,7 @@ def docstring_to_markdown(obj) -> str:
# Highlight section headers for Markdown
headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"]
for h in headers:
text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text)
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:

View File

@@ -322,36 +322,28 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
self.validation_msg_md.emit("")
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
"""
Simple HTML formatting for validation messages, wrapping text naturally.
Args:
device_name (str): The name of the device.
raw_msg (str): The raw validation message.
"""
if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...":
"""Simple HTML formatting for validation messages, wrapping text naturally."""
if not raw_msg.strip():
return f"### Validation in progress for {device_name}... \n\n"
if raw_msg == "Validation in progress...":
return f"### Validation in progress for {device_name}... \n\n"
# Regex to capture repeated ERROR patterns
pat = re.compile(
r"ERROR:\s*(?P<device>[^\s]+)\s+"
r"(?P<status>is not valid|is not connectable|failed):\s*"
r"(?P<detail>.*?)(?=ERROR:|$)",
re.DOTALL,
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}"]
# 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
)
blocks = []
for m in pat.finditer(raw_msg):
dev = m.group("device")
status = m.group("status")
detail = m.group("detail").strip()
lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"]
blocks.append("\n\n".join(lines))
# Fallback: If no patterns matched, return the raw message
if not blocks:
return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```"
for m in field_pat.finditer(raw_msg):
field = m.group("field")
rest = m.group("rest").rstrip()
lines.append(f"### {field}")
lines.append(rest)
return "\n\n---\n\n".join(blocks)
return "\n".join(lines)
def validation_running(self):
return self._device_list_items != {}
@@ -394,7 +386,7 @@ if __name__ == "__main__":
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/first_light.yaml"
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}")

View File

@@ -467,8 +467,6 @@ class ScanControl(BECWidget, QWidget):
for box in self.kwarg_boxes:
box_kwargs = box.get_parameters(bec_object)
kwargs.update(box_kwargs)
if self._scan_metadata is not None:
kwargs["metadata"] = self._scan_metadata
return args, kwargs
def restore_scan_parameters(self, scan_name: str):
@@ -521,6 +519,7 @@ class ScanControl(BECWidget, QWidget):
def run_scan(self):
"""Starts the selected scan with the given parameters."""
args, kwargs = self.get_scan_parameters()
kwargs["metadata"] = self._scan_metadata
self.scan_args.emit(args)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):

View File

@@ -3,7 +3,6 @@ from typing import Literal, Sequence
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal, Slot
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
@@ -198,12 +197,12 @@ class ScanGroupBox(QGroupBox):
# Add bundle button
self.button_add_bundle = QPushButton(self)
self.button_add_bundle.setIcon(
material_icon(icon_name="add", size=(15, 15), icon_type=QIcon)
material_icon(icon_name="add", size=(15, 15), convert_to_pixmap=False)
)
# Remove bundle button
self.button_remove_bundle = QPushButton(self)
self.button_remove_bundle.setIcon(
material_icon(icon_name="remove", size=(15, 15), icon_type=QIcon)
material_icon(icon_name="remove", size=(15, 15), convert_to_pixmap=False)
)
hbox_layout.addWidget(self.button_add_bundle)
hbox_layout.addWidget(self.button_remove_bundle)

View File

@@ -2,7 +2,6 @@ from bec_ipython_client.main import BECIPythonClient
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QMainWindow
@@ -10,10 +9,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
def __init__(self, inprocess: bool = False):
super().__init__()
self.inprocess = inprocess
self.ipyclient = None
self.inprocess = None
self.client = None
self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess)
self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess)
self.set_default_style("linux")
self._init_bec()
@@ -36,13 +35,14 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
self._init_bec_kernel()
def _init_bec_inprocess(self):
self.ipyclient = BECIPythonClient()
self.ipyclient.start()
self.client = BECIPythonClient()
self.client.start()
self.kernel_manager.kernel.shell.push(
{
"bec": self.ipyclient,
"dev": self.ipyclient.device_manager.devices,
"scans": self.ipyclient.scans,
"bec": self.client,
"dev": self.client.device_manager.devices,
"scans": self.client.scans,
}
)
@@ -57,47 +57,20 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
"""
)
def _cleanup_bec(self):
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:
self.ipyclient.shutdown()
self.ipyclient = None
def shutdown_kernel(self):
"""
Shutdown the Jupyter kernel and clean up resources.
"""
self._cleanup_bec()
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
self.kernel_client = None
self.kernel_manager = None
def closeEvent(self, event):
self.shutdown_kernel()
event.accept()
super().closeEvent(event)
class JupyterConsoleWindow(QMainWindow): # pragma: no cover:
def __init__(self, inprocess: bool = True, parent=None):
super().__init__(parent)
self.console = BECJupyterConsole(inprocess=inprocess)
self.setCentralWidget(self.console)
self.setAttribute(Qt.WA_DeleteOnClose, True)
def closeEvent(self, event):
# Explicitly close the console so its own closeEvent runs
if getattr(self, "console", None) is not None:
self.console.close()
event.accept()
super().closeEvent(event)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
win = JupyterConsoleWindow(inprocess=True)
win = QMainWindow()
win.setCentralWidget(BECJupyterConsole(True))
win.show()
sys.exit(app.exec_())

View File

@@ -1,469 +0,0 @@
from __future__ import annotations
import os
import pathlib
from typing import Any, cast
from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.ads as QtAds
from bec_widgets import BECWidget
from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class MonacoDock(BECWidget, QWidget):
"""
MonacoDock is a dock widget that contains Monaco editor instances.
It is used to manage multiple Monaco editors in a dockable interface.
"""
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)
# 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.dock_manager = QtAds.CDockManager(self)
self.dock_manager.setStyleSheet("")
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self._root_layout.addWidget(self.dock_manager)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: CDockWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
self.add_editor()
self._open_files = {}
def _create_editor(self):
init_lsp = len(self.dock_manager.dockWidgets()) == 0
widget = MonacoWidget(self, init_lsp=init_lsp)
widget.save_enabled.connect(self.save_enabled.emit)
widget.editor.signature_help_triggered.connect(self._on_signature_change)
count = len(self.dock_manager.dockWidgets())
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.DockWidgetFeature.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True)
dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget))
return dock
@property
def last_focused_editor(self) -> CDockWidget | None:
"""
Get the last focused editor.
"""
dock_widget = self.dock_manager.focusedDockWidget()
if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget):
self.last_focused_editor = dock_widget
return self._last_focused_editor
@last_focused_editor.setter
def last_focused_editor(self, editor: CDockWidget | None):
self._last_focused_editor = editor
self.focused_editor.emit(editor)
def _on_last_focused_editor_changed(self, editor: CDockWidget | None):
if editor is None:
self.save_enabled.emit(False)
return
widget = cast(MonacoWidget, editor.widget())
if widget.modified:
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:
self.signature_help.emit("")
return
active_sig = signatures[signature.get("activeSignature", 0)]
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
# Get signature label and documentation
label = active_sig.get("label", "")
doc_obj = active_sig.get("documentation", {})
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
# Format the markdown output
markdown = f"```python\n{label}\n```\n\n{documentation}"
self.signature_help.emit(markdown)
def _on_focus_event(self, old_widget, new_widget) -> None:
# Track focus events for the dock widget
widget = new_widget.widget()
if isinstance(widget, MonacoWidget):
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:
# Prompt the user to save changes
response = QMessageBox.question(
self,
"Unsaved Changes",
"You have unsaved changes. Do you want to save them?",
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
| QMessageBox.StandardButton.Cancel,
)
if response == QMessageBox.StandardButton.Yes:
self.save_file(monaco_widget)
elif response == QMessageBox.StandardButton.Cancel:
return
# Count all editor docks managed by this dock manager
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("")
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
return
# Otherwise, proceed to close and delete the dock
monaco_widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
if self.last_focused_editor is dock:
self.last_focused_editor = None
# After topology changes, make sure single-tab areas get a plus button
QTimer.singleShot(0, self._scan_and_fix_areas)
def _ensure_area_plus(self, area):
if area is None:
return
# Only add once per area
if getattr(area, "_monaco_plus_btn", None) is not None:
return
# If the area has exactly one tab, inject a + button next to the tab bar
try:
tabbar = area.titleBar().tabBar()
count = tabbar.count() if hasattr(tabbar, "count") else 1
except Exception:
count = 1
if count >= 1:
plus_btn = QToolButton(area)
plus_btn.setText("+")
plus_btn.setToolTip("New Monaco Editor")
plus_btn.setAutoRaise(True)
tb = area.titleBar()
idx = tb.indexOf(tb.tabBar())
tb.insertWidget(idx + 1, plus_btn)
plus_btn.clicked.connect(lambda: self.add_editor(area))
# pylint: disable=protected-access
area._monaco_plus_btn = plus_btn
def _scan_and_fix_areas(self):
# Find all dock areas under this manager and ensure each single-tab area has a plus button
areas = self.dock_manager.findChildren(CDockAreaWidget)
for a in areas:
self._ensure_area_plus(a)
def eventFilter(self, obj, event):
# Track dock manager events
if obj is self.dock_manager and event.type() in (
QEvent.Type.ChildAdded,
QEvent.Type.ChildRemoved,
QEvent.Type.LayoutRequest,
):
QTimer.singleShot(0, self._scan_and_fix_areas)
return super().eventFilter(obj, event)
def add_editor(
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
): # Any as qt ads does not return a proper type
"""
Adds a new Monaco editor dock widget to the dock manager.
"""
new_dock = self._create_editor()
if title is not None:
new_dock.setWindowTitle(title)
if tooltip is not None:
new_dock.setTabToolTip(tooltip)
if area is None:
area_obj = self.dock_manager.addDockWidgetTab(
QtAds.DockWidgetArea.TopDockWidgetArea, new_dock
)
self._ensure_area_plus(area_obj)
else:
# If an area is provided, add the dock to that area
self.dock_manager.addDockWidgetTabToArea(new_dock, area)
self._ensure_area_plus(area)
QTimer.singleShot(0, self._scan_and_fix_areas)
return new_dock
def open_file(self, file_name: str, scope: str | None = None) -> None:
"""
Open a file in the specified area. If the file is already open, activate it.
"""
open_files = self._get_open_files()
if file_name in open_files:
dock = self._get_editor_dock(file_name)
if dock is not None:
dock.setAsCurrentTab()
return
file = os.path.basename(file_name)
# If the current editor is empty, we reuse it
# For now, the dock manager is only for the editor docks. We can therefore safely assume
# that all docks are editor docks.
dock_area = self.dock_manager.dockArea(0)
if not dock_area:
return
editor_dock = dock_area.currentDockWidget()
if not editor_dock:
return
editor_widget = editor_dock.widget() if editor_dock else None
if editor_widget:
editor_widget = cast(MonacoWidget, editor_dock.widget())
if editor_widget.current_file is None and editor_widget.get_text() == "":
editor_dock.setWindowTitle(file)
editor_dock.setTabToolTip(file_name)
editor_widget.open_file(file_name)
if scope is not None:
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)
if scope is not None:
widget.metadata["scope"] = scope
editor_dock.setAsCurrentTab()
def save_file(
self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True
) -> None:
"""
Save the currently focused file.
Args:
widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used.
force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved.
format_on_save (bool): If True, format the code before saving if it's a Python file.
"""
if widget is None:
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)
return
# Save as option
save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)")
if not save_file or not save_file[0]:
return
# check if we have suffix specified
file = pathlib.Path(save_file[0])
if file.suffix == "":
file = file.with_suffix(".py")
if format_on_save and file.suffix == ".py":
widget.format()
text = widget.get_text()
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))
logger.debug(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.
Args:
enabled (bool): Whether to enable or disable Vim mode.
"""
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
editor_widget.set_vim_mode_enabled(enabled)
def _get_open_files(self) -> list[str]:
open_files = []
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file is not None:
open_files.append(editor_widget.current_file)
return open_files
def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None:
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file == file_name:
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
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
_dock = MonacoDock()
_dock.show()
sys.exit(app.exec())

View File

@@ -1,24 +1,11 @@
from __future__ import annotations
from typing import Literal
import os
import traceback
from typing import TYPE_CHECKING, Literal
import black
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
from bec_widgets.utils.error_popups import SafeSlot
if TYPE_CHECKING:
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
logger = bec_logger.logger
class MonacoWidget(BECWidget, QWidget):
@@ -27,7 +14,6 @@ class MonacoWidget(BECWidget, QWidget):
"""
text_changed = Signal(str)
save_enabled = Signal(bool)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
@@ -35,7 +21,6 @@ class MonacoWidget(BECWidget, QWidget):
"get_text",
"insert_text",
"delete_line",
"open_file",
"set_language",
"get_language",
"set_theme",
@@ -52,9 +37,7 @@ class MonacoWidget(BECWidget, QWidget):
"screenshot",
]
def __init__(
self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs
):
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
@@ -64,30 +47,7 @@ class MonacoWidget(BECWidget, QWidget):
layout.addWidget(self.editor)
self.setLayout(layout)
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 = {}
if init_lsp:
self.editor.update_workspace_configuration(
{
"pylsp": {
"plugins": {
"pylsp-bec": {"service_config": self.client._service_config.config}
}
}
}
)
@property
def current_file(self):
"""
Get the current file being edited.
"""
return self._current_file
def apply_theme(self, theme: str | None = None) -> None:
"""
@@ -101,19 +61,14 @@ class MonacoWidget(BECWidget, QWidget):
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> None:
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
file_name (str): Set the file name
reset (bool): If True, reset the original content to the new text.
"""
self._current_file = file_name if file_name else self._current_file
if reset:
self._original_content = text
self.editor.set_text(text, uri=file_name)
self.editor.set_text(text)
def get_text(self) -> str:
"""
@@ -121,32 +76,6 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_text()
def format(self) -> None:
"""
Format the current text in the Monaco editor.
"""
if not self.editor:
return
try:
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
formatted_content = content
config = isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
self.set_text(formatted_content, file_name=self.current_file)
except Exception:
content = traceback.format_exc()
logger.info(content)
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
@@ -167,32 +96,6 @@ class MonacoWidget(BECWidget, QWidget):
"""
self.editor.delete_line(line)
def open_file(self, file_name: str) -> None:
"""
Open a file in the editor.
Args:
file_name (str): The path + file name of the file that needs to be displayed.
"""
if not os.path.exists(file_name):
raise FileNotFoundError(f"The specified file does not exist: {file_name}")
with open(file_name, "r", encoding="utf-8") as file:
content = file.read()
self.set_text(content, file_name=file_name, reset=True)
@property
def modified(self) -> bool:
"""
Check if the editor content has been modified.
"""
return self._original_content != self.get_text()
@SafeSlot(str)
def _check_save_status(self, _text: str) -> None:
self.save_enabled.emit(self.modified)
def set_cursor(
self,
line: int,
@@ -310,46 +213,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)
self._run_dialog_and_insert_code(dialog)
def _run_dialog_and_insert_code(self, dialog: ScanControlDialog):
"""
Run the dialog and insert the generated scan code if accepted.
It is a separate method to allow easier testing.
Args:
dialog (ScanControlDialog): The scan control dialog instance.
"""
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([])
@@ -371,7 +234,7 @@ if TYPE_CHECKING:
scans: Scans
#######################################
########## User Script ################
########## User Script #####################
#######################################
# This is a comment

View File

@@ -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()]
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_())

View File

@@ -172,17 +172,9 @@ class WebConsole(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "terminal"
def __init__(
self,
parent=None,
config=None,
client=None,
gui_id=None,
startup_cmd: str | None = "bec --nogui",
**kwargs,
):
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = startup_cmd
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token

View File

@@ -4,7 +4,7 @@ import time
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPen, QPixmap
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -87,7 +87,7 @@ class Pos(QWidget):
if self.is_revealed:
if self.is_mine:
p.drawPixmap(r, material_icon("experiment", icon_type=QPixmap, filled=True))
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
elif self.adjacent_n > 0:
pen = QPen(NUM_COLORS[self.adjacent_n])
@@ -103,7 +103,7 @@ class Pos(QWidget):
material_icon(
"flag",
size=(50, 50),
icon_type=QPixmap,
convert_to_pixmap=True,
filled=True,
color=self.palette().base().color(),
),
@@ -376,13 +376,13 @@ class Minesweeper(BECWidget, QWidget):
self.status = status
match status:
case GameStatus.READY:
icon = material_icon(icon_name="add", icon_type=QIcon)
icon = material_icon(icon_name="add", convert_to_pixmap=False)
case GameStatus.PLAYING:
icon = material_icon(icon_name="smart_toy", icon_type=QIcon)
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
case GameStatus.FAILED:
icon = material_icon(icon_name="error", icon_type=QIcon)
icon = material_icon(icon_name="error", convert_to_pixmap=False)
case GameStatus.SUCCESS:
icon = material_icon(icon_name="celebration", icon_type=QIcon)
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
self.reset_button.setIcon(icon)
def update_timer(self):

View File

@@ -1,12 +1,12 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor, QIcon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHBoxLayout,
@@ -62,7 +62,7 @@ class ROILockButton(QToolButton):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), icon_type=QIcon))
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
class ROIPropertyTree(BECWidget, QWidget):
@@ -73,16 +73,11 @@ class ROIPropertyTree(BECWidget, QWidget):
- Children: type, line-width (spin box), coordinates (auto-updating).
Args:
parent (QWidget, optional): Parent widget. Defaults to None.
image_widget (Image): The main Image widget that displays the ImageItem.
Provides ``plot_item`` and owns an ROIController already.
controller (ROIController, optional): Optionally pass an external controller.
If None, the manager uses ``image_widget.roi_controller``.
compact (bool, optional): If True, use a compact mode with no tree view,
only a toolbar with draw actions. Defaults to False.
compact_orientation (str, optional): Orientation of the toolbar in compact mode.
Either "vertical" or "horizontal". Defaults to "vertical".
compact_color (str, optional): Color of the single active ROI in compact mode.
parent (QWidget, optional): Parent widget. Defaults to None.
"""
PLUGIN = False
@@ -97,18 +92,11 @@ class ROIPropertyTree(BECWidget, QWidget):
parent: QWidget = None,
image_widget: Image,
controller: ROIController | None = None,
compact: bool = False,
compact_orientation: Literal["vertical", "horizontal"] = "vertical",
compact_color: str = "#f0f0f0",
):
super().__init__(
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
)
self.compact = compact
self.compact_orient = compact_orientation
self.compact_color = compact_color
self.single_active_roi: BaseROI | None = None
if controller is None:
# Use the controller already belonging to the Image widget
@@ -124,29 +112,22 @@ class ROIPropertyTree(BECWidget, QWidget):
self.layout = QVBoxLayout(self)
self._init_toolbar()
if not self.compact:
self._init_tree()
else:
self.tree = None
self._init_tree()
# connect controller
self.controller.roiAdded.connect(self._on_roi_added)
self.controller.roiRemoved.connect(self._on_roi_removed)
if not self.compact:
self.controller.cleared.connect(self.tree.clear)
self.controller.cleared.connect(self.tree.clear)
# initial load
for r in self.controller.rois:
self._on_roi_added(r)
if not self.compact:
self.tree.collapseAll()
self.tree.collapseAll()
# --------------------------------------------------------------------- UI
def _init_toolbar(self):
tb = self.toolbar = ModularToolBar(
self, orientation=self.compact_orient if self.compact else "horizontal"
)
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) ---
@@ -176,29 +157,6 @@ class ROIPropertyTree(BECWidget, QWidget):
for mode, act in self._draw_actions.items():
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
if self.compact:
tb.components.add_safe(
"compact_delete",
MaterialIconAction("delete", "Delete Current Roi", checkable=False, parent=self),
)
bundle.add_action("compact_delete")
tb.components.get_action("compact_delete").action.triggered.connect(
lambda _: (
self.controller.remove_roi(self.single_active_roi)
if self.single_active_roi is not None
else None
)
)
tb.show_bundles(["roi_draw"])
self.layout.addWidget(tb)
# ROI drawing state (needed even in compact mode)
self._roi_draw_mode = None
self._roi_start_pos = None
self._temp_roi = None
self.plot.scene().installEventFilter(self)
return
# Expand/Collapse toggle
self.expand_toggle = MaterialIconAction(
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
@@ -209,11 +167,11 @@ class ROIPropertyTree(BECWidget, QWidget):
if on:
# switched to expanded state
self.tree.expandAll()
new_icon = material_icon("unfold_less", size=(20, 20), icon_type=QIcon)
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
else:
# collapsed state
self.tree.collapseAll()
new_icon = material_icon("unfold_more", size=(20, 20), icon_type=QIcon)
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
self.expand_toggle.action.setIcon(new_icon)
self.expand_toggle.action.toggled.connect(_exp_toggled)
@@ -231,7 +189,7 @@ class ROIPropertyTree(BECWidget, QWidget):
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), icon_type=QIcon
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
@@ -369,21 +327,13 @@ class ROIPropertyTree(BECWidget, QWidget):
self._set_roi_draw_mode(None)
# register via controller
self.controller.add_roi(final_roi)
if self.compact:
final_roi.line_color = self.compact_color
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
if self.compact:
roi.line_color = self.compact_color
if self.single_active_roi is not None and self.single_active_roi is not roi:
self.controller.remove_roi(self.single_active_roi)
self.single_active_roi = roi
return
# check the global setting from the toolbar
if hasattr(self, "lock_all_action") and self.lock_all_action.action.isChecked():
if self.lock_all_action.action.isChecked():
roi.movable = False
# parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""])
@@ -402,7 +352,11 @@ class ROIPropertyTree(BECWidget, QWidget):
# delete button
del_btn = QToolButton()
delete_icon = material_icon(
"delete", size=(20, 20), icon_type=QIcon, filled=False, color=self.DELETE_BUTTON_COLOR
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
del_btn.setIcon(delete_icon)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
@@ -470,10 +424,6 @@ class ROIPropertyTree(BECWidget, QWidget):
roi.movable = not roi.movable
def _on_roi_removed(self, roi: BaseROI):
if self.compact:
if self.single_active_roi is roi:
self.single_active_roi = None
return
item = self.roi_items.pop(roi, None)
if item:
idx = self.tree.indexOfTopLevelItem(item)
@@ -499,9 +449,8 @@ class ROIPropertyTree(BECWidget, QWidget):
self.controller.remove_roi(roi)
def cleanup(self):
if hasattr(self, "cmap"):
self.cmap.close()
self.cmap.deleteLater()
self.cmap.close()
self.cmap.deleteLater()
if self.controller and hasattr(self.controller, "rois"):
for roi in self.controller.rois: # disconnect all signals from ROIs
try:
@@ -542,8 +491,8 @@ if __name__ == "__main__": # pragma: no cover
# Add the image widget on the left
ml.addWidget(image_widget)
# ROI manager linked to that image with compact mode
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget, compact=True)
# ROI manager linked to that image
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
mgr.setFixedWidth(350)
ml.addWidget(mgr)

View File

@@ -767,7 +767,7 @@ class MotorMap(PlotBase):
float: Motor initial position.
"""
entry = self.entry_validator.validate_signal(name, None)
init_position = round(float(self.dev[name].read(cached=True)[entry]["value"]), precision)
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
return init_position
def _sync_motor_map_selection_toolbar(self):

View File

@@ -174,8 +174,6 @@ class BaseROI(BECConnector):
self.remove_scale_handles() # remove any existing handles from pyqtgraph.RectROI
if movable:
self.add_scale_handle() # add custom scale handles
if hasattr(self, "sigRemoveRequested"):
self.sigRemoveRequested.connect(self.remove)
def set_parent(self, parent: Image):
"""

View File

@@ -42,15 +42,10 @@ class CurveConfig(ConnectionConfig):
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
"solid", description="The style of the pen of the curve."
)
source: Literal["device", "dap", "custom", "history"] = Field(
source: Literal["device", "dap", "custom"] = Field(
"custom", description="The source of the curve."
)
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
scan_id: str | None = Field(None, description="Scan ID to be used when `source` is 'history'.")
scan_number: int | None = Field(
None, description="Scan index to be used when `source` is 'history'."
)
current_x_mode: str | None = Field(None, description="The current x mode of the history curve.")
parent_label: str | None = Field(
None, description="The label of the parent plot, only relevant for dap curves."
)
@@ -204,7 +199,7 @@ class Curve(BECConnector, pg.PlotDataItem):
Raises:
ValueError: If the source is not custom.
"""
if self.config.source in ["custom", "history"]:
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")

View File

@@ -5,34 +5,7 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QIcon, QValidator
class ScanIndexValidator(QValidator):
"""Validator to allow only 'live' or integer scan numbers from an allowed set."""
def __init__(self, allowed_scans: set[int] | None = None, parent=None):
super().__init__(parent)
self.allowed_scans = allowed_scans or set()
def validate(self, input_str: str, pos: int):
# Accept empty or 'live'
if input_str == "" or input_str == "live":
return QValidator.State.Acceptable, input_str, pos
# Allow partial editing of "live"
if "live".startswith(input_str):
return QValidator.State.Intermediate, input_str, pos
# Accept integer only if present in the allowed set
if input_str.isdigit():
try:
num = int(input_str)
except ValueError:
return QValidator.State.Invalid, input_str, pos
if num in self.allowed_scans:
return QValidator.State.Acceptable, input_str, pos
return QValidator.State.Invalid, input_str, pos
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -120,60 +93,8 @@ class CurveRow(QTreeWidgetItem):
# Create columns 1..2, depending on source
self._init_source_ui()
# Create columns 3..6 (color, style, width, symbol)
self._init_scan_index_ui()
self._init_style_controls()
def _init_scan_index_ui(self):
"""Create the Scan # editable combobox in column 3."""
if self.source not in ("device", "history"):
return
self.scan_index_combo = QComboBox()
self.scan_index_combo.setEditable(True)
# Populate 'live' and all available history scan indices
self.scan_index_combo.addItem("live", None)
scan_number_list = []
scan_id_list = []
try:
history = getattr(self.curve_tree.client, "history", None)
if history is not None:
scan_number_list = getattr(history, "_scan_numbers", []) or []
scan_id_list = getattr(history, "_scan_ids", []) or []
except Exception as e:
logger.error(f"Cannot fetch scan numbers from BEC client: {e}")
# If scan numbers cannot be fetched, only provide 'live' option
scan_number_list = []
scan_id_list = []
# Restrict input to 'live' or valid scan numbers
allowed = set()
try:
allowed = set(int(n) for n in scan_number_list if isinstance(n, (int, str)))
except Exception:
allowed = set()
validator = ScanIndexValidator(allowed, self.scan_index_combo)
self.scan_index_combo.lineEdit().setValidator(validator)
# Add items: show scan numbers, store scan IDs as item data
if scan_number_list and scan_id_list and len(scan_number_list) == len(scan_id_list):
for num, sid in zip(scan_number_list, scan_id_list):
self.scan_index_combo.addItem(str(num), sid)
else:
logger.error("Scan number and ID lists are mismatched or empty.")
# Select current based on existing config
selected = False
if getattr(self.config, "scan_id", None): # scan_id matching only
for i in range(self.scan_index_combo.count()):
if self.scan_index_combo.itemData(i) == self.config.scan_id:
self.scan_index_combo.setCurrentIndex(i)
selected = True
break
if not selected:
self.scan_index_combo.setCurrentText("live")
self.tree.setItemWidget(self, 3, self.scan_index_combo)
def _init_actions(self):
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
self.actions_widget = QWidget()
@@ -184,19 +105,23 @@ class CurveRow(QTreeWidgetItem):
# Delete button
self.delete_button = QToolButton()
delete_icon = material_icon(
"delete", size=(20, 20), icon_type=QIcon, filled=False, color=self.DELETE_BUTTON_COLOR
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
self.delete_button.setIcon(delete_icon)
self.delete_button.clicked.connect(lambda: self.remove_self())
actions_layout.addWidget(self.delete_button)
# If device row, add "Add DAP" button
if self.source in ("device", "history"):
if self.source == "device":
self.add_dap_button = QToolButton()
analysis_icon = material_icon(
"monitoring",
size=(20, 20),
icon_type=QIcon,
convert_to_pixmap=False,
filled=False,
color=self.app.theme.colors["FG"].toTuple(),
)
@@ -209,7 +134,7 @@ class CurveRow(QTreeWidgetItem):
def _init_source_ui(self):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source in ("device", "history"):
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceComboBox(parent=self.tree)
self.device_edit.insertItem(0, "")
@@ -238,6 +163,7 @@ class CurveRow(QTreeWidgetItem):
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
else:
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
@@ -256,31 +182,31 @@ class CurveRow(QTreeWidgetItem):
self.tree.setItemWidget(self, 2, self.dap_combo)
def _init_style_controls(self):
"""Create columns 4..7: color button, style combo, width spin, symbol spin."""
# Color in col 4
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.color_changed.connect(self._on_color_changed)
self.tree.setItemWidget(self, 4, self.color_button)
self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 5
# Style in col 4
self.style_combo = QComboBox()
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
idx = self.style_combo.findText(self.config.pen_style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
self.tree.setItemWidget(self, 5, self.style_combo)
self.tree.setItemWidget(self, 4, self.style_combo)
# Pen width in col 6
# Pen width in col 5
self.width_spin = QSpinBox()
self.width_spin.setRange(1, 20)
self.width_spin.setValue(self.config.pen_width)
self.tree.setItemWidget(self, 6, self.width_spin)
self.tree.setItemWidget(self, 5, self.width_spin)
# Symbol size in col 7
# Symbol size in col 6
self.symbol_spin = QSpinBox()
self.symbol_spin.setRange(1, 20)
self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 7, self.symbol_spin)
self.tree.setItemWidget(self, 6, self.symbol_spin)
@SafeSlot(str, verify_sender=True)
def _on_color_changed(self, new_color: str):
@@ -294,8 +220,8 @@ class CurveRow(QTreeWidgetItem):
self.config.symbol_color = new_color
def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source is 'device' or 'history'."""
if self.source not in ("device", "history"):
"""Create a new DAP row as a child. Only valid if source='device'."""
if self.source != "device":
return
curve_tree = self.tree.parent()
parent_label = self.config.label
@@ -373,7 +299,7 @@ class CurveRow(QTreeWidgetItem):
Returns:
dict: The serialized config based on the GUI state.
"""
if self.source in ("device", "history"):
if self.source == "device":
# Gather device name/entry
device_name = ""
device_entry = ""
@@ -394,23 +320,8 @@ class CurveRow(QTreeWidgetItem):
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
scan_combo_text = self.scan_index_combo.currentText()
if scan_combo_text == "live" or scan_combo_text == "":
self.config.scan_number = None
self.config.scan_id = None
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
if scan_combo_text.isdigit():
try:
scan_num = int(scan_combo_text)
except ValueError:
scan_num = None
self.config.scan_number = scan_num
self.config.scan_id = self.scan_index_combo.currentData()
self.config.source = "history"
# Label history curves with scan number suffix
if scan_num is not None:
self.config.label = f"{device_name}-{device_entry}-scan-{scan_num}"
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
else:
# DAP logic
parent_conf_dict = {}
@@ -543,12 +454,10 @@ class CurveTree(BECWidget, QWidget):
self.toolbar.show_bundles(["curve_tree"])
def _init_tree(self):
"""Initialize the QTreeWidget with 8 columns and compact widths."""
"""Initialize the QTreeWidget with 7 columns and compact widths."""
self.tree = QTreeWidget()
self.tree.setColumnCount(8)
self.tree.setHeaderLabels(
["Actions", "Name", "Entry", "Scan #", "Color", "Style", "Width", "Symbol"]
)
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
@@ -558,10 +467,10 @@ class CurveTree(BECWidget, QWidget):
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(4, 70)
self.tree.setColumnWidth(5, 80)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 50)
self.tree.setColumnWidth(7, 50)
self.layout.addWidget(self.tree)
@@ -685,9 +594,9 @@ class CurveTree(BECWidget, QWidget):
self.tree.clear()
self.all_items = []
top_curves = [c for c in self.waveform.curves if c.config.source in ("device", "history")]
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
for dev in top_curves:
for dev in device_curves:
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:

View File

@@ -8,7 +8,6 @@ import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.scan_data_container import ScanDataContainer
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtWidgets import (
@@ -36,9 +35,6 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
logger = bec_logger.logger
@@ -170,7 +166,6 @@ class Waveform(PlotBase):
# Curve data
self._sync_curves = []
self._async_curves = []
self._history_curves = []
self._slice_index = None
self._dap_curves = []
self._mode = None
@@ -187,14 +182,12 @@ class Waveform(PlotBase):
"readout_priority": None,
"label_suffix": "",
}
self._current_x_device: tuple[str, str] | None = None
# Specific GUI elements
self._init_roi_manager()
self.dap_summary = None
self.dap_summary_dialog = None
self.scan_history_dialog = None
self._add_waveform_specific_popup()
self._add_fit_parameters_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self.curve_settings_dialog = None
@@ -262,7 +255,7 @@ class Waveform(PlotBase):
super().add_side_menus()
self._add_dap_summary_side_menu()
def _add_waveform_specific_popup(self):
def _add_fit_parameters_popup(self):
"""
Add popups to the Waveform widget.
"""
@@ -272,24 +265,11 @@ class Waveform(PlotBase):
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
),
)
self.toolbar.components.add_safe(
"scan_history",
MaterialIconAction(
icon_name="manage_search",
tooltip="Open Scan History browser",
checkable=True,
parent=self,
),
)
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
self.toolbar.get_bundle("axis_popup").add_action("scan_history")
self.toolbar.components.get_action("fit_params").action.triggered.connect(
self.show_dap_summary_popup
)
self.toolbar.components.get_action("scan_history").action.triggered.connect(
self.show_scan_history_popup
)
@SafeSlot()
def _reset_view(self):
@@ -437,47 +417,6 @@ class Waveform(PlotBase):
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
self._roi_manager.toggle_roi(False)
################################################################################
# Scan History browser popup
# TODO this is so far quick implementation just as popup, we should make scan history also standalone widget later
def show_scan_history_popup(self):
"""
Show the scan history popup.
"""
scan_history_action = self.toolbar.components.get_action("scan_history").action
if self.scan_history_dialog is None or not self.scan_history_dialog.isVisible():
self.scan_history_widget = ScanHistoryBrowser(parent=self)
self.scan_history_dialog = QDialog(modal=False)
self.scan_history_dialog.setWindowTitle(f"{self.object_name} - Scan History Browser")
self.scan_history_dialog.layout = QVBoxLayout(self.scan_history_dialog)
self.scan_history_dialog.layout.addWidget(self.scan_history_widget)
self.scan_history_widget.scan_history_device_viewer.request_history_plot.connect(
lambda scan_id, device_name, signal_name: self.plot(
y_name=device_name, y_entry=signal_name, scan_id=scan_id
)
)
self.scan_history_dialog.finished.connect(self._scan_history_closed)
self.scan_history_dialog.show()
self.scan_history_dialog.resize(780, 320)
scan_history_action.setChecked(True)
else:
# If already open, bring it to the front
self.scan_history_dialog.raise_()
self.scan_history_dialog.activateWindow()
scan_history_action.setChecked(True) # keep it toggle
def _scan_history_closed(self):
"""
Slot for when the scan history dialog is closed.
"""
if self.scan_history_dialog is None:
return
self.scan_history_widget.close()
self.scan_history_widget.deleteLater()
self.scan_history_dialog.deleteLater()
self.scan_history_dialog = None
self.toolbar.components.get_action("scan_history").action.setChecked(False)
################################################################################
# Dap Summary
@@ -567,11 +506,7 @@ class Waveform(PlotBase):
self.x_axis_mode["name"] = value
if value not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(value, None)
self._current_x_device = (value, self.x_axis_mode["entry"])
self._switch_x_axis_item(mode=value)
self._current_x_device = None
self._refresh_history_curves()
self._update_curve_visibility()
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
@@ -599,8 +534,6 @@ class Waveform(PlotBase):
return
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
self._switch_x_axis_item(mode="device")
self._refresh_history_curves()
self._update_curve_visibility()
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
@@ -741,8 +674,6 @@ class Waveform(PlotBase):
color: str | None = None,
label: str | None = None,
dap: str | None = None,
scan_id: str | None = None,
scan_number: int | None = None,
**kwargs,
) -> Curve:
"""
@@ -765,10 +696,6 @@ class Waveform(PlotBase):
dap(str): The dap model to use for the curve, only available for sync devices.
If not specified, none will be added.
Use the same string as is the name of the LMFit model.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
Returns:
Curve: The curve object.
@@ -838,8 +765,6 @@ class Waveform(PlotBase):
label=label,
color=color,
source=source,
scan_id=scan_id,
scan_number=scan_number,
**kwargs,
)
@@ -847,9 +772,6 @@ class Waveform(PlotBase):
if source == "device":
config.signal = DeviceSignal(name=y_name, entry=y_entry)
if scan_id is not None or scan_number is not None:
config.source = "history"
# CREATE THE CURVE
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
@@ -888,7 +810,7 @@ class Waveform(PlotBase):
device_curve = self._find_curve_by_label(device_label)
if not device_curve:
raise ValueError(f"No existing curve found with label '{device_label}'.")
if device_curve.config.source not in ("device", "history"):
if device_curve.config.source != "device":
raise ValueError(
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
)
@@ -897,7 +819,7 @@ class Waveform(PlotBase):
dev_entry = device_curve.config.signal.entry
# 2) Build a label for the new DAP curve
dap_label = f"{device_label}-{dap_name}"
dap_label = f"{dev_name}-{dev_entry}-{dap_name}"
# 3) Possibly raise if the DAP curve already exists
if self._check_curve_id(dap_label):
@@ -950,23 +872,7 @@ class Waveform(PlotBase):
ValueError: If a duplicate curve label/config is found, or if
custom data is missing for `source='custom'`.
"""
scan_item: ScanDataContainer | None = None
if config.source == "history":
scan_item = self.get_history_scan_item(
scan_id=config.scan_id, scan_index=config.scan_number
)
if scan_item is None:
raise ValueError(
f"Could not find scan item for history curve '{config.label}' with scan_id='{config.scan_id}' and scan_number='{config.scan_number}'."
)
config.scan_id = scan_item.metadata["bec"]["scan_id"]
config.scan_number = scan_item.metadata["bec"]["scan_number"]
label = config.label
if config.source == "history":
label = f"{config.signal.name}-{config.signal.entry}-scan-{config.scan_number}"
config.label = label
if not label:
# Fallback label
label = WidgetContainerUtils.generate_unique_name(
@@ -988,7 +894,7 @@ class Waveform(PlotBase):
raise ValueError("For 'custom' curves, x_data and y_data must be provided.")
# Actually create the Curve item
curve = self._add_curve_object(name=label, config=config, scan_item=scan_item)
curve = self._add_curve_object(name=label, config=config)
# If custom => set initial data
if config.source == "custom" and x_data is not None and y_data is not None:
@@ -1005,8 +911,6 @@ class Waveform(PlotBase):
self.setup_dap_for_scan()
self.roi_enable.emit(True) # Enable the ROI toolbar action
self.request_dap() # Request DAP update directly without blocking proxy
if config.source == "history":
self._history_curves.append(curve)
QTimer.singleShot(
150, self.auto_range
@@ -1014,175 +918,24 @@ class Waveform(PlotBase):
return curve
def _add_curve_object(
self, name: str, config: CurveConfig, scan_item: ScanDataContainer | None = None
) -> Curve | None:
def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:
"""
Low-level creation of the PlotDataItem (Curve) from a `CurveConfig`.
Args:
name (str): The name/label of the curve.
config (CurveConfig): Configuration model describing the curve.
scan_item (ScanDataContainer | None): Optional scan item for history curves.
Returns:
Curve: The newly created curve object, added to the plot.
"""
curve = Curve(config=config, name=name, parent_item=self)
self.plot_item.addItem(curve)
if scan_item is not None:
self._fetch_history_data_for_curve(curve, scan_item)
self._categorise_device_curves()
curve.visibleChanged.connect(self._refresh_crosshair_markers)
curve.visibleChanged.connect(self.auto_range)
return curve
def _fetch_history_data_for_curve(
self, curve: Curve, scan_item: ScanDataContainer
) -> Curve | None:
# Check if the data are already set
device = curve.config.signal.name
entry = curve.config.signal.entry
all_devices_used = getattr(
getattr(scan_item, "_msg", None), "stored_data_info", None
) or getattr(scan_item, "stored_data_info", None)
if all_devices_used is None:
curve.remove()
raise ValueError(
f"No stored data info found in scan item ID:{curve.config.scan_id} for curve '{curve.name()}'. "
f"Upgrade BEC to the latest version."
)
# 1. get y data
x_data, y_data = None, None
if device not in all_devices_used:
raise ValueError(f"Device '{device}' not found in scan item ID:{curve.config.scan_id}.")
if entry not in all_devices_used[device]:
raise ValueError(
f"Entry '{entry}' not found in device '{device}' in scan item ID:{curve.config.scan_id}."
)
y_shape = all_devices_used.get(device).get(entry).shape[0]
# Determine X-axis data
if self.x_axis_mode["name"] == "index":
x_data = np.arange(y_shape)
curve.config.current_x_mode = "index"
self._update_x_label_suffix(" (index)")
elif self.x_axis_mode["name"] == "timestamp":
y_device = scan_item.devices.get(device)
x_data = y_device.get(entry).read().get("timestamp")
curve.config.current_x_mode = "timestamp"
self._update_x_label_suffix(" (timestamp)")
elif self.x_axis_mode["name"] not in ("index", "timestamp", "auto"): # Custom device mode
if self.x_axis_mode["name"] not in all_devices_used:
logger.warning(
f"Custom device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_entry_custom = self.x_axis_mode.get("entry")
if x_entry_custom is None:
x_entry_custom = self.entry_validator.validate_signal(
self.x_axis_mode["name"], None
)
if x_entry_custom not in all_devices_used[self.x_axis_mode["name"]]:
logger.warning(
f"Custom entry '{x_entry_custom}' for device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_shape = (
scan_item._msg.stored_data_info.get(self.x_axis_mode["name"])
.get(x_entry_custom)
.shape[0]
)
if x_shape != y_shape:
logger.warning(
f"Shape mismatch for x data '{x_shape}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_device = scan_item.devices.get(self.x_axis_mode["name"])
x_data = x_device.get(x_entry_custom).read().get("value")
curve.config.current_x_mode = self.x_axis_mode["name"]
self._update_x_label_suffix(f" (custom: {self.x_axis_mode['name']}-{x_entry_custom})")
elif self.x_axis_mode["name"] == "auto":
if (
self._current_x_device is None
): # Scenario where no x device is set yet, because there was no live scan done in this widget yet
# If no current x device, use the first motor from scan item
scan_motors = self._ensure_str_list(
scan_item.metadata.get("bec").get("scan_report_devices")
)
if not scan_motors: # scan was done without reported motor from whatever reason
x_data = np.arange(y_shape) # Fallback to index
y_data = scan_item.devices.get(device).get(entry).read().get("value")
curve.set_data(x=x_data, y=y_data)
self._update_x_label_suffix(" (auto: index)")
return curve
x_entry = self.entry_validator.validate_signal(scan_motors[0], None)
if x_entry not in all_devices_used.get(scan_motors[0], {}):
logger.warning(
f"Auto x entry '{x_entry}' for device '{scan_motors[0]}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
if y_shape != all_devices_used.get(scan_motors[0]).get(x_entry, {}).shape[0]:
logger.warning(
f"Shape mismatch for x data '{all_devices_used.get(scan_motors[0]).get(x_entry, {}).get('shape', [0])[0]}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_data = scan_item.devices.get(scan_motors[0]).get(x_entry).read().get("value")
self._current_x_device = (scan_motors[0], x_entry)
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
curve.config.current_x_mode = "auto"
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
else: # Scan in auto mode was done and live scan already set the current x device
if self._current_x_device[0] not in all_devices_used:
logger.warning(
f"Auto x data for device '{self._current_x_device[0]}' "
f"and entry '{self._current_x_device[1]}'"
f" not found in scan item of the history curve {curve.name()}."
)
curve.setVisible(False)
return
x_device = scan_item.devices.get(self._current_x_device[0])
x_data = x_device.get(self._current_x_device[1]).read().get("value")
curve.config.current_x_mode = "auto"
self._update_x_label_suffix(
f" (auto: {self._current_x_device[0]}-{self._current_x_device[1]})"
)
if x_data is None:
logger.warning(
f"X data for curve '{curve.name()}' could not be determined. "
f"Check if the x_mode '{self.x_axis_mode['name']}' is valid for the scan item."
)
curve.setVisible(False)
return
if y_data is None:
y_data = scan_item.devices.get(device).get(entry).read().get("value")
if y_data is None:
logger.warning(
f"Y data for curve '{curve.name()}' could not be determined. "
f"Check if the device '{device}' and entry '{entry}' are valid for the scan item."
)
curve.setVisible(False)
return
curve.set_data(x=x_data, y=y_data)
return curve
def _refresh_history_curves(self):
for curve in self._history_curves:
scan_item = self.get_history_scan_item(
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
)
if scan_item is not None:
self._fetch_history_data_for_curve(curve, scan_item)
else:
logger.warning(f"Scan item for curve {curve.name()} not found.")
def _refresh_crosshair_markers(self):
"""
Refresh the crosshair markers when a curve visibility changes.
@@ -1217,42 +970,7 @@ class Waveform(PlotBase):
Clear all data from the plot widget, but keep the curve references.
"""
for c in self.curves:
if c.config.source != "history":
c.clear_data()
# X-axis compatibility helpers
def _is_curve_compatible(self, curve: Curve) -> bool:
"""
Return True when *curve* can be shown with the current x-axis mode.
- index, timestamp are always compatible.
- For history curves we check whether the requested motor
(self.x_axis_mode["name"]) exists in the cached
history_data_buffer["x"] dictionary.
- DAP is done by checking if the parent curve is visible.
- Device curves are fetched by update sync/async curves, which solves the compatibility there.
"""
mode = self.x_axis_mode.get("name", "index")
if mode in ("index", "timestamp"): # always compatible - wild west mode
return True
if curve.config.source == "history":
scan_item = self.get_history_scan_item(
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
)
curve = self._fetch_history_data_for_curve(curve, scan_item)
if curve is None:
return False
if curve.config.source == "dap":
parent_curve = self._find_curve_by_label(curve.config.parent_label)
if parent_curve.isVisible():
return True
return False # DAP curve is not compatible if parent curve is not visible
return True
def _update_curve_visibility(self) -> None:
"""Show or hide curves according to `_is_curve_compatible`."""
for c in self.curves:
c.setVisible(self._is_curve_compatible(c))
c.clear_data()
def clear_all(self):
"""
@@ -1415,7 +1133,7 @@ class Waveform(PlotBase):
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
self._slice_index = None # Reset the slice index
self._update_curve_visibility()
self._mode = self._categorise_device_curves()
# First trigger to sync and async data
@@ -1493,7 +1211,7 @@ class Waveform(PlotBase):
device_data = entry_obj.read()["value"] if entry_obj else None
x_data = self._get_x_data(device_name, device_entry)
if x_data is not None:
if np.isscalar(x_data):
if len(x_data) == 1:
self.clear_data()
return
if device_data is not None and x_data is not None:
@@ -1901,7 +1619,6 @@ class Waveform(PlotBase):
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else [0]
new_suffix = f" (custom: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
@@ -1916,13 +1633,11 @@ class Waveform(PlotBase):
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
x_data = timestamps
new_suffix = " (timestamp)"
self._current_x_device = None
# 3 User wants index
if self.x_axis_mode["name"] == "index":
x_data = None
new_suffix = " (index)"
self._current_x_device = None
# 4 Best effort automatic mode
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
@@ -1930,7 +1645,6 @@ class Waveform(PlotBase):
if len(self._async_curves) > 0:
x_data = None
new_suffix = " (auto: index)"
self._current_x_device = None
# 4.2 If there are sync curves, use the first device from the scan report
else:
try:
@@ -1953,7 +1667,6 @@ class Waveform(PlotBase):
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
self._update_x_label_suffix(new_suffix)
return x_data
@@ -2056,83 +1769,49 @@ class Waveform(PlotBase):
logger.info(f"Scan {self.scan_id} => mode={self._mode}")
return mode
def get_history_scan_item(
self, scan_index: int = None, scan_id: str = None
) -> ScanDataContainer | None:
"""
Get scan item from history based on scan_id or scan_index.
If both are provided, scan_id takes precedence and the resolved scan_number
will be read from the fetched item.
Args:
scan_id (str, optional): ScanID of the scan to fetch. Defaults to None.
scan_index (int, optional): Index (scan number) of the scan to fetch. Defaults to None.
Returns:
ScanDataContainer | None: The fetched scan item or None if no item was found.
"""
if scan_index is not None and scan_id is not None:
scan_index = None # Prefer scan_id when both are given
if scan_index is None and scan_id is None:
logger.warning("Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is None:
return self.client.history.get_by_scan_id(scan_id)
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return None
return scan_item
if len(self.client.history) == 0:
logger.info("No scans executed so far. Cannot fetch scan history.")
return None
# check if scan_index is negative, then fetch it just from the list from the end
if int(scan_index) < 0:
return self.client.history[scan_index]
scan_item = self.client.history.get_by_scan_number(scan_index)
if scan_item is None:
logger.warning(f"Scan with scan_number {scan_index} not found in history.")
return None
if isinstance(scan_item, list):
if len(scan_item) > 1:
logger.warning(
f"Multiple scans found with scan_number {scan_index}. Returning the latest one."
)
scan_item = scan_item[-1]
return scan_item
@SafeSlot(int)
@SafeSlot(str)
@SafeSlot()
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
self.scan_item = self.get_history_scan_item(scan_index=scan_index, scan_id=scan_id)
if scan_index is not None and scan_id is not None:
raise ValueError("Only one of scan_id or scan_index can be provided.")
if self.scan_item is None:
if scan_index is None and scan_id is None:
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self._emit_signal_update()
return
if scan_id is not None:
self.scan_id = scan_id
else:
# If scan_number was used, set the scan_id from the fetched item
if hasattr(self.scan_item, "metadata"):
self.scan_id = self.scan_item.metadata["bec"]["scan_id"]
else:
self.scan_id = self.scan_item.scan_id
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self._emit_signal_update()
return
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
self._emit_signal_update()
@@ -2363,9 +2042,6 @@ class Waveform(PlotBase):
if self.dap_summary_dialog is not None:
self.dap_summary_dialog.reject()
self.dap_summary_dialog = None
if self.scan_history_dialog is not None:
self.scan_history_dialog.reject()
self.scan_history_dialog = None
super().cleanup()

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal, Slot
from qtpy.QtGui import QColor, QIcon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -240,7 +240,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
abort_button.button.setText("")
abort_button.button.setIcon(
material_icon("cancel", color="#cc181e", filled=True, icon_type=QIcon)
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
)
abort_button.setStyleSheet(
"""

View File

@@ -10,7 +10,6 @@ from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_qthemes import material_icon
from pyqtgraph import SignalProxy
from qtpy.QtCore import QThreadPool, Signal
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -98,7 +97,7 @@ class DeviceBrowser(BECWidget, QWidget):
def init_tool_buttons(self):
def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ""):
button.clicked.connect(slot)
button.setIcon(material_icon(icon, size=(20, 20), icon_type=QIcon))
button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
button.setToolTip(tooltip)
_setup_button(self.ui.add_button, "add", self._create_add_dialog, "add new device")

View File

@@ -1,7 +1,6 @@
from bec_lib.device import Device
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -39,8 +38,8 @@ class SignalDisplay(BECWidget, QWidget):
@SafeSlot()
def _refresh(self):
if (dev := self.dev.get(self.device)) is not None:
dev.read(cached=True)
dev.read_configuration(cached=True)
dev.read()
dev.read_configuration()
def _add_refresh_button(self):
button_holder = QWidget()
@@ -48,7 +47,9 @@ class SignalDisplay(BECWidget, QWidget):
button_holder.layout().setAlignment(Qt.AlignmentFlag.AlignRight)
button_holder.layout().setContentsMargins(0, 0, 0, 0)
refresh_button = QToolButton()
refresh_button.setIcon(material_icon(icon_name="refresh", size=(20, 20), icon_type=QIcon))
refresh_button.setIcon(
material_icon(icon_name="refresh", size=(20, 20), convert_to_pixmap=False)
)
refresh_button.clicked.connect(self._refresh)
button_holder.layout().addWidget(refresh_button)
self._content_layout.addWidget(button_holder)

View File

@@ -1,19 +1,13 @@
import datetime
import importlib
import importlib.metadata
import os
import re
from typing import Literal
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
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
@@ -23,19 +17,16 @@ class IDEExplorer(BECWidget, QWidget):
PLUGIN = True
RPC = False
file_open_requested = Signal(str, str)
file_preview_requested = Signal(str, str)
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):
@@ -44,16 +35,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)
@@ -61,29 +46,15 @@ class IDEExplorer(BECWidget, QWidget):
match section_name.lower():
case "scripts":
self.add_script_section()
case "macros":
self.add_macro_section()
case _:
pass
def _remove_section(self, section_name):
section = self.main_explorer.get_section(section_name.upper())
if section:
self.main_explorer.remove_section(section)
self._sections.remove(section_name)
def clear(self):
"""Clear all sections from the explorer."""
for section in reversed(self._sections):
self._remove_section(section)
def add_script_section(self):
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
section.expanded = False
script_explorer = Explorer(parent=self)
script_widget = ScriptTreeWidget(parent=self)
script_widget.file_open_requested.connect(self._emit_file_open_scripts_local)
script_widget.file_selected.connect(self._emit_file_preview_scripts_local)
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
local_scripts_section.set_widget(script_widget)
@@ -96,96 +67,24 @@ class IDEExplorer(BECWidget, QWidget):
section.set_widget(script_explorer)
self.main_explorer.add_section(section)
plugin_scripts_dir = self._get_plugin_dir("scripts")
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_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)
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 = self._get_plugin_dir("macros")
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 _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None:
"""Get the path to the specified directory within the BEC plugin.
Returns:
The path to the specified directory, or None if not found.
"""
plugin_scripts_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
return os.path.join(plugin.__path__[0], dir_name)
return None
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
break
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")
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
return
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)
# 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_local_script(self):
"""Show a dialog to enter the name of a new script and create it."""
@@ -237,134 +136,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("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

View File

@@ -272,9 +272,7 @@ class SignalLabel(BECWidget, QWidget):
if not isinstance(self._device_obj, Device | Signal):
self._value, self._units = "__", ""
return
reading = (self._device_obj.read(cached=True) or {}) | (
self._device_obj.read_configuration(cached=True) or {}
)
reading = (self._device_obj.read() or {}) | (self._device_obj.read_configuration() or {})
value = reading.get(self._signal_key, {}).get("value")
if value is None:
self._value, self._units = "__", ""

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Slot
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -90,7 +89,9 @@ class DarkModeButton(BECWidget, QWidget):
def update_mode_button(self):
icon = material_icon(
"light_mode" if self.dark_mode_enabled else "dark_mode", size=(20, 20), icon_type=QIcon
"light_mode" if self.dark_mode_enabled else "dark_mode",
size=(20, 20),
convert_to_pixmap=False,
)
self.mode_button.setIcon(icon)
self.mode_button.setToolTip("Set Light Mode" if self.dark_mode_enabled else "Set Dark Mode")

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.41.1"
version = "2.39.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -13,8 +13,8 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client~=3.70", # needed for jupyter console
"bec_lib~=3.70",
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_lib~=3.52",
"bec_qthemes~=1.0, >=1.1.2",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
@@ -24,13 +24,9 @@ dependencies = [
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"thefuzz~=0.22",
"qtmonaco~=0.8, >=0.8.1",
"qtmonaco~=0.7",
"darkdetect~=0.8",
"PySide6-QtAds==4.4.0",
"pylsp-bec~=1.2",
"copier~=9.7",
"typer~=0.15",
"markdown~=3.9",
]
@@ -48,6 +44,7 @@ dev = [
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
]
[project.urls]

View File

@@ -286,85 +286,3 @@ def test_waveform_passing_device(qtbot, bec_client_lib, connected_client_gui_obj
# check plotted data
x_data, y_data = c1.get_data()
assert np.array_equal(y_data, last_scan_data.devices.samx.samx_setpoint.read().get("value"))
@pytest.mark.timeout(120)
@pytest.mark.parametrize(
"history_selector", ["scan_id", "scan_number"]
) # ensure unique curves per run
def test_rpc_waveform_history_curve(
qtbot, bec_client_lib, connected_client_gui_obj, history_selector
):
"""
E2E test for the new history curve feature:
- Run 3 scans
- For each scan, fetch history curve data using either scan_id OR scan_number (parametrized)
- Compare waveform data with BEC client scan data
Note: Parameterization prevents adding the same logical curve twice (which would collide on label).
"""
gui = connected_client_gui_obj
dock = gui.bec
client = bec_client_lib
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
wf = dock.new("wf_dock").new("Waveform")
# Collect references for validation
scan_meta = [] # list of dicts with scan_id, scan_number, data
# Run 3 scans and collect their metadata and data
for i in range(3):
status = scans.line_scan(dev.samx, -5 + i, 5 + i, steps=10, exp_time=0.01, relative=False)
status.wait()
# Wait until the history entry appears and corresponds to this scan
def _wait_for_scan_in_history():
if len(client.history) == 0:
return False
return client.history[-1].metadata.bec.get("scan_id", None) == status.scan.scan_id
qtbot.waitUntil(_wait_for_scan_in_history, timeout=10000)
hist_item = client.history[-1]
item = queue.scan_storage.storage[-1]
data = item.live_data if hasattr(item, "live_data") else item.data
scan_meta.append(
{
"scan_id": hist_item.metadata.bec.get("scan_id"),
"scan_number": hist_item.metadata.bec.get("scan_number"),
"data": data,
}
)
# For each scan, fetch history curve by the chosen selector and compare to client data
for meta in scan_meta:
sel_value = meta[history_selector]
scan_data = meta["data"]
# Add curve from history using the chosen selector; single curve per scan to avoid duplicates
kwargs = {history_selector: sel_value}
curve = wf.plot(x_name="samx", y_name="bpm4i", **kwargs)
num_elements = 10
# Wait until curve has the expected number of points
def _curve_ready():
try:
x, y = curve.get_data()
except Exception:
return False
return x is not None and len(x) == num_elements and len(y) == num_elements
qtbot.waitUntil(_curve_ready, timeout=10000)
# Get plotted data
x_vals, y_vals = curve.get_data()
# Compare against BEC client scan data
np.testing.assert_equal(x_vals, np.array(scan_data["samx"]["samx"].val))
np.testing.assert_equal(y_vals, np.array(scan_data["bpm4i"]["bpm4i"].val))
# Clean up
curve.remove()

View File

@@ -7,7 +7,6 @@ import pytest
from bec_lib.bec_service import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from bec_lib.scan_history import ScanHistory
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
@@ -239,18 +238,3 @@ def create_dummy_scan_item():
"scan_report_devices": ["samx"],
}
return dummy_scan
def inject_scan_history(widget, scan_history_factory, *history_args):
"""
Helper to inject scan history messages into client history.
"""
history_msgs = []
for scan_id, scan_number in history_args:
history_msgs.append(scan_history_factory(scan_id=scan_id, scan_number=scan_number))
widget.client.history = ScanHistory(widget.client, False)
for msg in history_msgs:
widget.client.history._scan_data[msg.scan_id] = msg
widget.client.history._scan_ids.append(msg.scan_id)
widget.client.queue.scan_storage.current_scan = None
return history_msgs

View File

@@ -5,11 +5,10 @@ import h5py
import numpy as np
import pytest
from bec_lib import messages
from bec_lib.messages import _StoredDataInfo
from bec_qthemes import apply_theme
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
@@ -41,10 +40,6 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
# if the test failed, we don't want to check for open widgets as
# it simply pollutes the output
# stop pyepics dispatcher for leaking tests
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
if request.node.stash._storage.get("failed"):
print("Test failed, skipping cleanup checks")
return
@@ -85,14 +80,6 @@ def clean_singleton():
error_popups._popup_utility_instance = None
@pytest.fixture(autouse=True)
def suppress_message_box(monkeypatch):
"""
Auto-suppress any QMessageBox.exec_ calls by returning Ok immediately.
"""
monkeypatch.setattr(QMessageBox, "exec_", lambda *args, **kwargs: QMessageBox.Ok)
def create_widget(qtbot, widget, *args, **kwargs):
"""
Create a widget and add it to the qtbot for testing. This is a helper function that
@@ -139,25 +126,9 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
elif isinstance(sub_value, dict):
for sub_sub_key, sub_sub_value in sub_value.items():
sub_sub_group = metadata_bec[key].create_group(sub_key)
# Handle _StoredDataInfo objects
if isinstance(sub_sub_value, _StoredDataInfo):
# Store the numeric shape
sub_sub_group.create_dataset("shape", data=sub_sub_value.shape)
# Store the dtype as a UTF-8 string
dt = sub_sub_value.dtype or ""
sub_sub_group.create_dataset(
"dtype", data=dt, dtype=h5py.string_dtype(encoding="utf-8")
)
continue
if isinstance(sub_sub_value, list):
json_val = json.dumps(sub_sub_value)
sub_sub_group.create_dataset(sub_sub_key, data=json_val)
elif isinstance(sub_sub_value, dict):
for k2, v2 in sub_sub_value.items():
val = json.dumps(v2) if isinstance(v2, list) else v2
sub_sub_group.create_dataset(k2, data=val)
else:
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
sub_sub_value = json.dumps(sub_sub_value)
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
else:
metadata_bec[key].create_dataset(sub_key, data=sub_value)
else:
@@ -184,8 +155,6 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
end_time=time.time(),
num_points=metadata["num_points"],
request_inputs=metadata["request_inputs"],
stored_data_info=metadata.get("stored_data_info"),
metadata={"scan_report_devices": metadata.get("scan_report_devices")},
)
return msg
@@ -236,102 +205,3 @@ def grid_scan_history_msg(tmpdir):
file_path = str(tmpdir.join("scan_1.h5"))
return create_history_file(file_path, data, metadata)
@pytest.fixture
def scan_history_factory(tmpdir):
"""
Factory to create scan history messages with custom parameters.
Usage:
msg1 = scan_history_factory(scan_id="id1", scan_number=1, num_points=10)
msg2 = scan_history_factory(scan_id="id2", scan_number=2, scan_name="grid_scan", num_points=16)
"""
def _factory(
scan_id: str = "test_scan",
scan_number: int = 1,
dataset_number: int = 1,
scan_name: str = "line_scan",
scan_type: str = "step",
num_points: int = 10,
x_range: tuple = (-5, 5),
y_range: tuple = (-5, 5),
):
# Generate positions based on scan type
if scan_name == "grid_scan":
grid_size = int(np.sqrt(num_points))
x_grid, y_grid = np.meshgrid(
np.linspace(x_range[0], x_range[1], grid_size),
np.linspace(y_range[0], y_range[1], grid_size),
)
x_flat = x_grid.T.ravel()
y_flat = y_grid.T.ravel()
else:
x_flat = np.linspace(x_range[0], x_range[1], num_points)
y_flat = np.linspace(y_range[0], y_range[1], num_points)
positions = np.vstack((x_flat, y_flat)).T
num_pts = len(positions)
# Create dummy data
data = {
"baseline": {"bpm1a": {"bpm1a": {"value": [1], "timestamp": [100]}}},
"monitored": {
"bpm4i": {
"bpm4i": {
"value": np.random.rand(num_points),
"timestamp": np.random.rand(num_points),
}
},
"bpm3a": {
"bpm3a": {
"value": np.random.rand(num_points),
"timestamp": np.random.rand(num_points),
}
},
"samx": {"samx": {"value": x_flat, "timestamp": np.arange(num_pts)}},
"samy": {"samy": {"value": y_flat, "timestamp": np.arange(num_pts)}},
},
"async": {
"async_device": {
"async_device": {
"value": np.random.rand(num_pts * 10),
"timestamp": np.random.rand(num_pts * 10),
}
}
},
}
metadata = {
"scan_id": scan_id,
"scan_name": scan_name,
"scan_type": scan_type,
"exit_status": "closed",
"scan_number": scan_number,
"dataset_number": dataset_number,
"request_inputs": {
"arg_bundle": [
"samx",
x_range[0],
x_range[1],
num_pts,
"samy",
y_range[0],
y_range[1],
num_pts,
],
"kwargs": {"relative": True},
},
"positions": positions.tolist(),
"num_points": num_pts,
"stored_data_info": {
"samx": {"samx": _StoredDataInfo(shape=(num_points,), dtype="float64")},
"samy": {"samy": _StoredDataInfo(shape=(num_points,), dtype="float64")},
"bpm4i": {"bpm4i": _StoredDataInfo(shape=(10,), dtype="float64")},
"async_device": {
"async_device": _StoredDataInfo(shape=(num_points * 10,), dtype="float64")
},
},
"scan_report_devices": [b"samx"],
}
file_path = str(tmpdir.join(f"{scan_id}.h5"))
return create_history_file(file_path, data, metadata)
return _factory

View File

@@ -306,7 +306,7 @@ class TestToolbarFunctionality:
def test_toolbar_utils_actions(self, advanced_dock_area):
"""Test utils toolbar actions trigger widget creation."""
utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"]
utils_actions = ["queue", "vs_code", "status", "progress_bar", "sbb_monitor"]
for action_name in utils_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
@@ -322,12 +322,7 @@ class TestToolbarFunctionality:
widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2]
action.trigger()
if action_name == "terminal":
mock_new.assert_called_once_with(
widget="WebConsole", closable=True, startup_cmd=None
)
else:
mock_new.assert_called_once_with(widget=widget_type)
mock_new.assert_called_once_with(widget=widget_type)
def test_attach_all_action(self, advanced_dock_area, qtbot):
"""Test attach_all toolbar action."""
@@ -879,10 +874,9 @@ class TestFlatToolbarActions:
"""Test that flat utils actions are created."""
utils_actions = [
"flat_queue",
"flat_vs_code",
"flat_status",
"flat_progress_bar",
"flat_terminal",
"flat_bec_shell",
"flat_log_panel",
"flat_sbb_monitor",
]
@@ -924,10 +918,9 @@ class TestFlatToolbarActions:
"""Test flat utils actions trigger widget creation."""
utils_action_mapping = {
"flat_queue": "BECQueue",
"flat_vs_code": "VSCodeEditor",
"flat_status": "BECStatusBox",
"flat_progress_bar": "RingProgressBar",
"flat_terminal": "WebConsole",
"flat_bec_shell": "WebConsole",
"flat_sbb_monitor": "SBBMonitor",
}

View File

@@ -1,119 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy.QtCore import QMimeData, QPoint, Qt
from qtpy.QtWidgets import QLabel
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
@pytest.fixture
def collapsible_section(qtbot):
"""Create a basic CollapsibleSection widget for testing"""
widget = CollapsibleSection(title="Test Section")
qtbot.addWidget(widget)
yield widget
@pytest.fixture
def dummy_content_widget(qtbot):
"""Create a simple widget to be used as content"""
widget = QLabel("Test Content")
qtbot.addWidget(widget)
return widget
def test_basic_initialization(collapsible_section):
"""Test basic initialization"""
assert collapsible_section.title == "Test Section"
assert collapsible_section.expanded is True
assert collapsible_section.content_widget is None
def test_toggle_expanded(collapsible_section):
"""Test toggling expansion state"""
assert collapsible_section.expanded is True
collapsible_section.toggle_expanded()
assert collapsible_section.expanded is False
collapsible_section.toggle_expanded()
assert collapsible_section.expanded is True
def test_set_widget(collapsible_section, dummy_content_widget):
"""Test setting content widget"""
collapsible_section.set_widget(dummy_content_widget)
assert collapsible_section.content_widget == dummy_content_widget
assert dummy_content_widget.parent() == collapsible_section
def test_connect_add_button(qtbot):
"""Test connecting add button"""
widget = CollapsibleSection(title="Test", show_add_button=True)
qtbot.addWidget(widget)
mock_slot = mock.MagicMock()
widget.connect_add_button(mock_slot)
qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton)
mock_slot.assert_called_once()
def test_section_reorder_signal(collapsible_section):
"""Test section reorder signal emission"""
signals_received = []
collapsible_section.section_reorder_requested.connect(
lambda source, target: signals_received.append((source, target))
)
# Create mock drop event
mime_data = QMimeData()
mime_data.setText("section:Source Section")
mock_event = mock.MagicMock()
mock_event.mimeData.return_value = mime_data
collapsible_section._header_drop_event(mock_event)
assert len(signals_received) == 1
assert signals_received[0] == ("Source Section", "Test Section")
def test_nested_collapsible_sections(qtbot):
"""Test that collapsible sections can be nested"""
# Create parent section
parent_section = CollapsibleSection(title="Parent Section")
qtbot.addWidget(parent_section)
# Create child section
child_section = CollapsibleSection(title="Child Section")
qtbot.addWidget(child_section)
# Add some content to the child section
child_content = QLabel("Child Content")
qtbot.addWidget(child_content)
child_section.set_widget(child_content)
# Nest the child section inside the parent
parent_section.set_widget(child_section)
# Verify nesting structure
assert parent_section.content_widget == child_section
assert child_section.parent() == parent_section
assert child_section.content_widget == child_content
assert child_content.parent() == child_section
# Test that both sections can expand/collapse independently
assert parent_section.expanded is True
assert child_section.expanded is True
# Collapse child section
child_section.toggle_expanded()
assert child_section.expanded is False
assert parent_section.expanded is True # Parent should remain expanded
# Collapse parent section
parent_section.toggle_expanded()
assert parent_section.expanded is False
assert child_section.expanded is False # Child state unchanged

View File

@@ -2,15 +2,10 @@ import json
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.scan_history import ScanHistory
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QComboBox, QVBoxLayout
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import (
CurveTree,
ScanIndexValidator,
)
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from tests.unit_tests.conftest import create_widget
@@ -160,7 +155,7 @@ def test_curve_tree_init(curve_tree_fixture):
curve_tree, wf = curve_tree_fixture
assert curve_tree.waveform == wf
assert curve_tree.color_palette == "plasma"
assert curve_tree.tree.columnCount() == 8
assert curve_tree.tree.columnCount() == 7
assert curve_tree.toolbar.components.exists("add")
assert curve_tree.toolbar.components.exists("expand")
@@ -379,54 +374,3 @@ def test_export_data_dap(curve_tree_fixture):
assert exported["signal"]["entry"] == "bpm4i"
assert exported["signal"]["dap"] == "GaussianModel"
assert exported["label"] == "bpm4i-bpm4i-GaussianModel"
def test_scan_index_validator_behavior():
"""
Test ScanIndexValidator allows empty, 'live', partial 'live', valid scan numbers,
and rejects invalid or disallowed inputs under the new allowed-set API.
"""
validator = ScanIndexValidator(allowed_scans={1, 2, 3})
def state(txt):
s, _, _ = validator.validate(txt, 0)
return s
assert state("") == QValidator.State.Acceptable
assert state("live") == QValidator.State.Acceptable
assert state("l") == QValidator.State.Intermediate
assert state("liv") == QValidator.State.Intermediate
assert state("1") == QValidator.State.Acceptable
assert state("3") == QValidator.State.Acceptable
assert state("4") == QValidator.State.Invalid
assert state("0") == QValidator.State.Invalid
assert state("abc") == QValidator.State.Invalid
def test_export_data_history_curve(curve_tree_fixture, scan_history_factory):
"""
Test that export_data for a history curve row correctly serializes scan_number
and resets scan_id when a numeric scan is selected.
"""
curve_tree, wf = curve_tree_fixture
# Inject two history scans into the waveform client
msgs = [
scan_history_factory(scan_id="hid1", scan_number=1),
scan_history_factory(scan_id="hid2", scan_number=2),
]
wf.client.history = ScanHistory(wf.client, False)
for m in msgs:
wf.client.history._scan_data[m.scan_id] = m
wf.client.history._scan_ids.append(m.scan_id)
wf.client.history._scan_numbers.append(m.scan_number)
wf.client.queue.scan_storage.current_scan = None
# Create a device row and select scan index "2"
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
device_row.scan_index_combo.setCurrentText("2")
exported = device_row.export_data()
assert exported["source"] == "history"
assert exported["scan_number"] == 2
assert exported["scan_id"] is None
assert exported["label"] == "bpm4i-bpm4i-scan-2"

View File

@@ -1,378 +0,0 @@
"""
Unit tests for the Developer View widget.
This module tests the DeveloperView widget functionality including:
- Widget initialization and setup
- Monaco editor integration
- IDE Explorer integration
- File operations (open, save, format)
- Context menu actions
- Toolbar functionality
"""
import os
import tempfile
from unittest import mock
import pytest
from qtpy.QtWidgets import QDialog
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
from .client_mocks import mocked_client
@pytest.fixture
def developer_view(qtbot, mocked_client):
"""Create a DeveloperWidget for testing."""
widget = DeveloperWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def temp_python_file():
"""Create a temporary Python file for testing."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"""# Test Python file
import os
import sys
def test_function():
return "Hello, World!"
if __name__ == "__main__":
print(test_function())
"""
)
temp_file_path = f.name
yield temp_file_path
# Cleanup
if os.path.exists(temp_file_path):
os.unlink(temp_file_path)
@pytest.fixture
def mock_scan_control_dialog():
"""Mock the ScanControlDialog for testing."""
with mock.patch(
"bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog"
) as mock_dialog:
# Configure the mock dialog
mock_dialog_instance = mock.MagicMock()
mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.get_scan_code.return_value = (
"scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)"
)
mock_dialog.return_value = mock_dialog_instance
yield mock_dialog_instance
class TestDeveloperViewInitialization:
"""Test developer view initialization and basic functionality."""
def test_developer_view_initialization(self, developer_view):
"""Test that the developer view initializes correctly."""
# Check that main components are created
assert hasattr(developer_view, "monaco")
assert hasattr(developer_view, "explorer")
assert hasattr(developer_view, "console")
assert hasattr(developer_view, "terminal")
assert hasattr(developer_view, "toolbar")
assert hasattr(developer_view, "dock_manager")
assert hasattr(developer_view, "plotting_ads")
assert hasattr(developer_view, "signature_help")
def test_monaco_editor_integration(self, developer_view):
"""Test that Monaco editor is properly integrated."""
assert isinstance(developer_view.monaco, MonacoDock)
assert developer_view.monaco.parent() is not None
def test_ide_explorer_integration(self, developer_view):
"""Test that IDE Explorer is properly integrated."""
assert isinstance(developer_view.explorer, IDEExplorer)
assert developer_view.explorer.parent() is not None
def test_toolbar_components(self, developer_view):
"""Test that toolbar components are properly set up."""
assert developer_view.toolbar is not None
# Check for expected toolbar actions
toolbar_components = developer_view.toolbar.components
expected_actions = ["save", "save_as", "run", "stop", "vim"]
for action_name in expected_actions:
assert toolbar_components.exists(action_name)
def test_dock_manager_setup(self, developer_view):
"""Test that dock manager is properly configured."""
assert developer_view.dock_manager is not None
# Check that docks are added
dock_widgets = developer_view.dock_manager.dockWidgets()
assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal
class TestFileOperations:
"""Test file operation functionality."""
def test_open_new_file(self, developer_view, temp_python_file, qtbot):
"""Test opening a new file in the Monaco editor."""
# Simulate opening a file through the IDE explorer signal
developer_view._open_new_file(temp_python_file, "scripts/local")
# Wait for the file to be loaded
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that the file was opened
assert temp_python_file in developer_view.monaco._get_open_files()
# Check that content was loaded (simplified check)
# Get the editor dock for the file and check its content
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
editor_widget = dock.widget()
assert "test_function" in editor_widget.get_text()
def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot):
"""Test that shared files are opened in read-only mode."""
# Open file with shared scope
developer_view._open_new_file(temp_python_file, "scripts/shared")
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that the file is set to read-only
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
monaco_widget = dock.widget()
# Check that the widget is in read-only mode
# This depends on MonacoWidget having a readonly property or method
assert monaco_widget is not None
def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot):
"""Test that file icons are assigned based on scope."""
# Test script file icon
developer_view._open_new_file(temp_python_file, "scripts/local")
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that an icon was set (simplified check)
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
assert not dock.icon().isNull()
def test_save_functionality(self, developer_view, qtbot):
"""Test the save functionality."""
# Get the currently focused editor widget (if any)
if developer_view.monaco.last_focused_editor:
editor_widget = developer_view.monaco.last_focused_editor.widget()
test_text = "print('Hello from save test')"
editor_widget.set_text(test_text)
qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000)
# Test the save action
with mock.patch.object(developer_view.monaco, "save_file") as mock_save:
developer_view.on_save()
mock_save.assert_called_once()
def test_save_as_functionality(self, developer_view, qtbot):
"""Test the save as functionality."""
# Get the currently focused editor widget (if any)
if developer_view.monaco.last_focused_editor:
editor_widget = developer_view.monaco.last_focused_editor.widget()
test_text = "print('Hello from save as test')"
editor_widget.set_text(test_text)
qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000)
# Test the save as action
with mock.patch.object(developer_view.monaco, "save_file") as mock_save:
developer_view.on_save_as()
mock_save.assert_called_once_with(force_save_as=True)
class TestMonacoEditorIntegration:
"""Test Monaco editor specific functionality."""
def test_vim_mode_toggle(self, developer_view, qtbot):
"""Test vim mode toggle functionality."""
# Test enabling vim mode
with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim:
developer_view.on_vim_triggered()
# The actual call depends on the checkbox state
mock_vim.assert_called_once()
def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot):
"""Test the Insert Scan context menu action."""
# This functionality is handled by individual MonacoWidget instances
# Test that the dock has editor widgets
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
assert len(dock_widgets) >= 1
# Test on the first available editor
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
assert isinstance(monaco_widget, MonacoWidget)
def test_context_menu_format_code(self, developer_view, qtbot):
"""Test the Format Code context menu action."""
# Get an editor widget from the dock manager
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
if dock_widgets:
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
# Set some unformatted Python code
unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x"
monaco_widget.set_text(unformatted_code)
qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000)
# Test format action on the individual widget
with mock.patch.object(monaco_widget, "format") as mock_format:
monaco_widget.format()
mock_format.assert_called_once()
def test_save_enabled_signal_handling(self, developer_view, qtbot):
"""Test that save enabled signals are handled correctly."""
# Mock the toolbar update method
with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update:
# Simulate save enabled signal
developer_view.monaco.save_enabled.emit(True)
mock_update.assert_called_with(True)
developer_view.monaco.save_enabled.emit(False)
mock_update.assert_called_with(False)
class TestIDEExplorerIntegration:
"""Test IDE Explorer integration."""
def test_file_open_signal_connection(self, developer_view):
"""Test that file open signals are properly connected."""
# Test that the signal connection works by mocking the connected method
with mock.patch.object(developer_view, "_open_new_file") as mock_open:
# Emit the signal to test the connection
developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local")
mock_open.assert_called_once_with("test_file.py", "scripts/local")
def test_file_preview_signal_connection(self, developer_view):
"""Test that file preview signals are properly connected."""
# Test that the signal exists and can be emitted (basic connection test)
try:
developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local")
# If no exception is raised, the signal exists and is connectable
assert True
except AttributeError:
assert False, "file_preview_requested signal not found"
def test_sections_configuration(self, developer_view):
"""Test that IDE Explorer sections are properly configured."""
assert "scripts" in developer_view.explorer.sections
assert "macros" in developer_view.explorer.sections
class TestToolbarIntegration:
"""Test toolbar functionality and integration."""
def test_toolbar_save_button_state(self, developer_view):
"""Test toolbar save button state management."""
# Test that save buttons exist and can be controlled
save_action = developer_view.toolbar.components.get_action("save")
save_as_action = developer_view.toolbar.components.get_action("save_as")
# Test that the actions exist and are accessible
assert save_action.action is not None
assert save_as_action.action is not None
# Test that they can be enabled/disabled via the update method
developer_view._on_save_enabled_update(False)
assert not save_action.action.isEnabled()
assert not save_as_action.action.isEnabled()
developer_view._on_save_enabled_update(True)
assert save_action.action.isEnabled()
assert save_as_action.action.isEnabled()
def test_vim_mode_button_toggle(self, developer_view, qtbot):
"""Test vim mode button toggle functionality."""
vim_action = developer_view.toolbar.components.get_action("vim")
if vim_action:
# Test toggling vim mode
initial_state = vim_action.action.isChecked()
# Simulate button click
vim_action.action.trigger()
# Check that state changed
assert vim_action.action.isChecked() != initial_state
class TestErrorHandling:
"""Test error handling in various scenarios."""
def test_invalid_scope_handling(self, developer_view, temp_python_file):
"""Test handling of invalid scope parameters."""
# Test with invalid scope
try:
developer_view._open_new_file(temp_python_file, "invalid/scope")
except Exception as e:
assert False, f"Invalid scope should be handled gracefully: {e}"
def test_monaco_editor_error_handling(self, developer_view):
"""Test error handling in Monaco editor operations."""
# Test with editor widgets from dock manager
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
if dock_widgets:
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
# Test setting invalid text
try:
monaco_widget.set_text(None) # This might cause an error
except Exception:
# Errors should be handled gracefully
pass
class TestSignalIntegration:
"""Test signal connections and data flow."""
def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot):
"""Test the complete file open signal flow."""
# Mock the _open_new_file method to verify it gets called
with mock.patch.object(developer_view, "_open_new_file") as mock_open:
# Emit the file open signal from explorer
developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local")
# Verify the signal was handled
mock_open.assert_called_once_with(temp_python_file, "scripts/local")
def test_save_enabled_signal_flow(self, developer_view, qtbot):
"""Test the save enabled signal flow."""
# Mock the update method (the actual method is _on_save_enabled_update)
with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update:
# Simulate monaco dock emitting save enabled signal
developer_view.monaco.save_enabled.emit(True)
# Verify the signal was handled
mock_update.assert_called_once_with(True)
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -137,7 +137,6 @@ def test_update_cycle(update_dialog, qtbot):
"description": None,
"readOnly": False,
"softwareTrigger": False,
"onFailure": "retry",
"deviceTags": set(),
"userParameter": {},
"name": "test_device",

View File

@@ -1,869 +0,0 @@
"""Unit tests for device_manager_components module."""
from unittest import mock
import pytest
import yaml
from bec_lib.atlas_models import Device as DeviceModel
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD
from bec_widgets.widgets.control.device_manager.components.device_table_view import (
USER_CHECK_DATA_ROLE,
BECTableView,
CenterCheckBoxDelegate,
CustomDisplayDelegate,
DeviceFilterProxyModel,
DeviceTableModel,
DeviceTableView,
DeviceValidatedDelegate,
DictToolTipDelegate,
WrappingTextDelegate,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import (
DocstringView,
docstring_to_markdown,
)
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
### Constants ####
def test_constants_headers_help_md():
"""Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format."""
assert isinstance(HEADERS_HELP_MD, dict)
expected_keys = {
"status",
"name",
"deviceClass",
"readoutPriority",
"deviceTags",
"enabled",
"readOnly",
"onFailure",
"softwareTrigger",
"description",
}
assert set(HEADERS_HELP_MD.keys()) == expected_keys
for _, value in HEADERS_HELP_MD.items():
assert isinstance(value, str)
assert value.startswith("## ") # Each entry should start with a markdown header
### DM Docstring View ####
@pytest.fixture
def docstring_view(qtbot):
"""Fixture to create a DocstringView instance."""
view = DocstringView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
class NumPyStyleClass:
"""Perform simple signal operations.
Parameters
----------
data : numpy.ndarray
Input signal data.
Attributes
----------
data : numpy.ndarray
The original signal data.
Returns
-------
SignalProcessor
An initialized signal processor instance.
"""
class GoogleStyleClass:
"""Analyze spectral properties of a signal.
Args:
frequencies (list[float]): Frequency bins.
amplitudes (list[float]): Corresponding amplitude values.
Returns:
dict: A dictionary with spectral analysis results.
Raises:
ValueError: If input lists are of unequal length.
"""
def test_docstring_view_docstring_to_markdown():
"""Test the docstring_to_markdown function with a sample class."""
numpy_md = docstring_to_markdown(NumPyStyleClass)
assert "# NumPyStyleClass" in numpy_md
assert "### Parameters" in numpy_md
assert "### Attributes" in numpy_md
assert "### Returns" in numpy_md
assert "```" in numpy_md # Check for code block formatting
google_md = docstring_to_markdown(GoogleStyleClass)
assert "# GoogleStyleClass" in google_md
assert "### Args" in google_md
assert "### Returns" in google_md
assert "### Raises" in google_md
assert "```" in google_md # Check for code block formatting
def test_docstring_view_on_select_config(docstring_view):
"""Test the DocstringView on_select_config method. Called with single and multiple devices."""
with (
mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class,
mock.patch.object(docstring_view, "_set_text") as mock_set_text,
):
# Test with single device
docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}])
mock_set_device_class.assert_called_once_with("NumPyStyleClass")
mock_set_device_class.reset_mock()
# Test with multiple devices, should not show anything
docstring_view.on_select_config(
[{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}]
)
mock_set_device_class.assert_not_called()
mock_set_text.assert_called_once_with("")
def test_docstring_view_set_device_class(docstring_view):
"""Test the DocstringView set_device_class method with valid and invalid class names."""
with mock.patch(
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class"
) as mock_get_plugin_class:
# Mock a valid class retrieval
mock_get_plugin_class.return_value = NumPyStyleClass
docstring_view.set_device_class("NumPyStyleClass")
assert "NumPyStyleClass" in docstring_view.toPlainText()
assert "Parameters" in docstring_view.toPlainText()
# Mock an invalid class retrieval
mock_get_plugin_class.side_effect = ImportError("Class not found")
docstring_view.set_device_class("NonExistentClass")
assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText()
# Test if READY_TO_VIEW is False
with mock.patch(
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW",
False,
):
call_count = mock_get_plugin_class.call_count
docstring_view.set_device_class("NumPyStyleClass") # Should do nothing
assert mock_get_plugin_class.call_count == call_count # No new calls made
#### DM Config View ####
@pytest.fixture
def dm_config_view(qtbot):
"""Fixture to create a DMConfigView instance."""
view = DMConfigView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_dm_config_view_initialization(dm_config_view):
"""Test DMConfigView proper initialization."""
# Check that the stacked layout is set up correctly
assert dm_config_view.stacked_layout is not None
assert dm_config_view.stacked_layout.count() == 2
# Assert Monaco editor is initialized
assert dm_config_view.monaco_editor.get_language() == "yaml"
assert dm_config_view.monaco_editor.editor._readonly is True
# Check overlay widget
assert dm_config_view._overlay_widget is not None
assert dm_config_view._overlay_widget.text() == "Select single device to show config"
# Check that overlay is initially shown
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
def test_dm_config_view_on_select_config(dm_config_view):
"""Test DMConfigView on_select_config with empty selection."""
# Test with empty list of configs
dm_config_view.on_select_config([])
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
# Test with a single config
cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
dm_config_view.on_select_config(cfgs)
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor
text = yaml.dump(cfgs[0], default_flow_style=False)
assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n")
# Test with multiple configs
cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
dm_config_view.on_select_config(cfgs)
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged
### Device Table View ####
# Not sure how to nicely test the delegates.
@pytest.fixture
def mock_table_view(qtbot):
"""Create a mock table view for delegate testing."""
table = BECTableView()
qtbot.addWidget(table)
qtbot.waitExposed(table)
yield table
@pytest.fixture
def device_table_model(qtbot, mock_table_view):
"""Fixture to create a DeviceTableModel instance."""
model = DeviceTableModel(mock_table_view)
yield model
@pytest.fixture
def device_proxy_model(qtbot, mock_table_view, device_table_model):
"""Fixture to create a DeviceFilterProxyModel instance."""
model = DeviceFilterProxyModel(mock_table_view)
model.setSourceModel(device_table_model)
mock_table_view.setModel(model)
yield model
@pytest.fixture
def qevent_mock() -> QtCore.QEvent:
"""Create a mock QEvent for testing."""
event = mock.MagicMock(spec=QtCore.QEvent)
yield event
@pytest.fixture
def view_mock() -> QtWidgets.QAbstractItemView:
"""Create a mock QAbstractItemView for testing."""
view = mock.MagicMock(spec=QtWidgets.QAbstractItemView)
yield view
@pytest.fixture
def index_mock(device_proxy_model) -> QtCore.QModelIndex:
"""Create a mock QModelIndex for testing."""
index = mock.MagicMock(spec=QtCore.QModelIndex)
index.model.return_value = device_proxy_model
yield index
@pytest.fixture
def option_mock() -> QtWidgets.QStyleOptionViewItem:
"""Create a mock QStyleOptionViewItem for testing."""
option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem)
yield option
@pytest.fixture
def painter_mock() -> QtGui.QPainter:
"""Create a mock QPainter for testing."""
painter = mock.MagicMock(spec=QtGui.QPainter)
yield painter
def test_tooltip_delegate(
mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model
):
"""Test DictToolTipDelegate tooltip generation."""
# No ToolTip event
delegate = DictToolTipDelegate(mock_table_view)
qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel
# nothing should happen
with mock.patch.object(
QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False
) as super_mock:
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock)
assert result is False
# ToolTip event
qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip
qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20))
source_model = device_proxy_model.sourceModel()
with (
mock.patch.object(
source_model, "get_row_data", return_value={"description": "Mock description"}
),
mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock),
mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock,
):
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock)
assert result is True
def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock):
"""Test CustomDisplayDelegate initialization."""
delegate = CustomDisplayDelegate(mock_table_view)
# Test _test_custom_paint, with None and a value
def _return_data():
yield None
yield "Test Value"
proxy_model = index_mock.model()
with (
mock.patch.object(proxy_model, "data", side_effect=_return_data()),
mock.patch.object(
QtWidgets.QStyledItemDelegate, "paint", return_value=None
) as super_paint_mock,
mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock,
):
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock)
custom_paint_mock.assert_not_called()
# Call again for the value case
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock)
assert super_paint_mock.call_count == 2
custom_paint_mock.assert_called_once_with(
painter_mock, option_mock, index_mock, "Test Value"
)
def test_center_checkbox_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test CenterCheckBoxDelegate initialization."""
delegate = CenterCheckBoxDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked)
# Check that the checkbox is centered
pixrect = delegate._icon_checked.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked)
model = index_mock.model()
# Editor event with non-check state role
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange
assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
# Editor event with check state role but not mouse button event
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease
with (
mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked),
mock.patch.object(model, "setData") as mock_model_set,
):
delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
mock_model_set.assert_called_once_with(
index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE
)
def test_device_validated_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
# Invalid value
delegate = DeviceValidatedDelegate(mock_table_view)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value")
painter_mock.drawPixmap.assert_not_called()
# Valid value
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value)
icon = delegate._icons[ValidationStatus.VALID.value]
pixrect = icon.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon)
def test_wrapping_text_delegate_do_custom_paint(
mock_table_view, painter_mock, option_mock, index_mock
):
"""Test WrappingTextDelegate _do_custom_paint method."""
delegate = WrappingTextDelegate(mock_table_view)
# First case, empty text, nothing should happen
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "")
painter_mock.setPen.assert_not_called()
layout_mock = mock.MagicMock()
def _layout_comput_return(*args, **kwargs):
return layout_mock
layout_mock.draw.return_value = None
with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return):
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring")
layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft())
TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20)
TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate."
def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock):
"""Test WrappingTextDelegate _compute_layout method."""
delegate = WrappingTextDelegate(mock_table_view)
layout_mock = mock.MagicMock(spec=QtGui.QTextLayout)
# This combination should yield 4 lines
with mock.patch.object(delegate, "_get_layout", return_value=layout_mock):
layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine)
mock_line.height.return_value = 10
mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False])
option_mock.rect = TEST_RECT_FOR
option_mock.font = QtGui.QFont()
layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock)
assert layout.createLine.call_count == 4 # pylint: disable=E1101
assert mock_line.setPosition.call_count == 3
assert mock_line.setPosition.call_args_list[-1] == mock.call(
QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit
)
def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock):
"""Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines."""
delegate = WrappingTextDelegate(mock_table_view)
assert delegate.margin == 6
with (
mock.patch.object(mock_table_view, "initViewItemOption"),
mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]),
mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]),
):
# Test with empty text, should return height + 2*margin
index_mock.data.return_value = ""
option_mock.rect = TEST_RECT_FOR
font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont())
size = delegate.sizeHint(option_mock, index_mock)
assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin)
# Now test with the text that should wrap to 4 lines
index_mock.data.return_value = TEST_TEXT_WITH_4_LINES
size = delegate.sizeHint(option_mock, index_mock)
# The estimate goes to 5 lines + 2* margin
expected_lines = 5
assert size == QtCore.QSize(
100, font_metrics.height() * expected_lines + 2 * delegate.margin
)
def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model):
"""Test WrappingTextDelegate update_row_heights method."""
device_cfg = DeviceModel(
name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline"
).model_dump()
# Add single device to config
delegate = WrappingTextDelegate(mock_table_view)
row_heights = [25, 40]
with mock.patch.object(
delegate,
"sizeHint",
side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])],
):
mock_table_view.setItemDelegateForColumn(5, delegate)
mock_table_view.setItemDelegateForColumn(6, delegate)
device_proxy_model.sourceModel().set_device_config([device_cfg])
assert delegate._wrapping_text_columns is None
assert mock_table_view.rowHeight(0) == 30 # Default height
delegate._update_row_heights()
assert delegate._wrapping_text_columns == [5, 6]
assert mock_table_view.rowHeight(0) == max(row_heights)
def test_device_validation_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
delegate = DeviceValidatedDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID)
# Check that the checkbox is centered
pixrect = delegate._icons[ValidationStatus.VALID.value].rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(
pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value]
)
# Should not be called if invalid value
delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10)
# Check that the checkbox is centered
assert painter_mock.drawPixmap.call_count == 1
###
# Test DeviceTableModel & DeviceFilterProxyModel
###
def test_device_table_model_data(device_proxy_model):
"""Test the device table model data retrieval."""
source_model = device_proxy_model.sourceModel()
test_device = {
"status": ValidationStatus.PENDING,
"name": "test_device",
"deviceClass": "TestClass",
"readoutPriority": "baseline",
"onFailure": "retry",
"enabled": True,
"readOnly": False,
"softwareTrigger": True,
"deviceTags": ["tag1", "tag2"],
"description": "Test device",
}
source_model.add_device_configs([test_device])
assert source_model.rowCount() == 1
assert source_model.columnCount() == 10
# Check data retrieval for each column
expected_data = {
0: ValidationStatus.PENDING, # Default status
1: "test_device", # name
2: "TestClass", # deviceClass
3: "baseline", # readoutPriority
4: "retry", # onFailure
5: "tag1, tag2", # deviceTags
6: "Test device", # description
7: True, # enabled
8: False, # readOnly
9: True, # softwareTrigger
}
for col, expected in expected_data.items():
index = source_model.index(0, col)
data = source_model.data(index, QtCore.Qt.DisplayRole)
assert data == expected
def test_device_table_model_with_data(device_table_model, device_proxy_model):
"""Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data."""
# Create 3 test devices - names NOT alphabetically sorted
test_devices = [
{
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
"readOnly": False,
"readoutPriority": "baseline",
"deviceTags": ["tag1", "tag2"],
"description": "Test device Z",
},
{
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
"readOnly": True,
"readoutPriority": "primary",
"deviceTags": ["tag3"],
"description": "Test device A",
},
{
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
"readOnly": False,
"readoutPriority": "secondary",
"deviceTags": [],
"description": "Test device B",
},
]
# Add devices to source model
device_table_model.add_device_configs(test_devices)
# Check source model has 3 rows and proper columns
assert device_table_model.rowCount() == 3
assert device_table_model.columnCount() == 10
# Check proxy model propagates the data
assert device_proxy_model.rowCount() == 3
assert device_proxy_model.columnCount() == 10
# Verify data propagation through proxy - check names in original order
for i, expected_device in enumerate(test_devices):
proxy_index = device_proxy_model.index(i, 1) # Column 1 is name
source_index = device_proxy_model.mapToSource(proxy_index)
source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
assert source_data == expected_device["name"]
# Check proxy data matches source
proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole)
assert proxy_data == source_data
# Verify all columns are accessible
headers = device_table_model.headers
for col, header in enumerate(headers):
header_data = device_table_model.headerData(
col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole
)
assert header_data is not None
def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort."""
# Use same test data as above - zebra, alpha, beta (not alphabetically sorted)
test_devices = [
{
"status": ValidationStatus.VALID,
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
},
{
"status": ValidationStatus.PENDING,
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
},
{
"status": ValidationStatus.FAILED,
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
},
]
device_table_model.add_device_configs(test_devices)
# Verify initial order (unsorted)
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
# Enable sorting and sort by name column (column 1)
mock_table_view.setSortingEnabled(True)
# header = mock_table_view.horizontalHeader()
# qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton)
device_proxy_model.sort(1, QtCore.Qt.AscendingOrder)
# After sorting, verify alphabetical order: alpha, beta, zebra
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (C): Remove rows from BECTableView and verify propagation."""
# Set up test data
test_devices = [
{"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True},
{"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False},
{"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True},
]
device_table_model.add_device_configs(test_devices)
assert device_table_model.rowCount() == 3
assert device_proxy_model.rowCount() == 3
# Mock the confirmation dialog to first cancel, then confirm
with mock.patch.object(
mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True]
) as mock_confirm:
# Create mock selection for middle device (device_to_remove at row 1)
selection_model = mock.MagicMock()
proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column
selection_model.selectedRows.return_value = [proxy_index_to_remove]
mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model)
# Verify the device we're about to remove
device_name_to_remove = device_proxy_model.data(
device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole
)
assert device_name_to_remove == "device_to_remove"
# Call delete_selected method
mock_table_view.delete_selected()
# Verify confirmation was called
mock_confirm.assert_called_once()
assert device_table_model.rowCount() == 3 # No change on first call
assert device_proxy_model.rowCount() == 3
# Call delete_selected again, this time it should confirm
mock_table_view.delete_selected()
# Check that the device was removed from source model
assert device_table_model.rowCount() == 2
assert device_proxy_model.rowCount() == 2
# Verify the remaining devices are correct
remaining_names = []
for i in range(device_proxy_model.rowCount()):
name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole)
remaining_names.append(name)
assert "device_to_remove" not in remaining_names
def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model):
"""Test DeviceFilterProxyModel text filtering functionality."""
# Set up test data with different device names and classes
test_devices = [
{"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"},
{"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"},
{"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"},
]
device_table_model.add_device_configs(test_devices)
assert device_proxy_model.rowCount() == 3
# Test filtering by name
device_proxy_model.setFilterText("motor")
assert device_proxy_model.rowCount() == 2
# Should show 2 rows (motor_x and motor_y)
visible_count = 0
for i in range(device_proxy_model.rowCount()):
if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
continue
visible_count += 1
# Test filtering by device class
device_proxy_model.setFilterText("EpicsDetector")
# Should show 1 row (detector_main)
detector_visible = False
assert device_proxy_model.rowCount() == 1
for i in range(device_table_model.rowCount()):
if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
source_index = device_table_model.index(i, 1) # Name column
name = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
if name == "detector_main":
detector_visible = True
break
assert detector_visible
# Clear filter
device_proxy_model.setFilterText("")
assert device_proxy_model.rowCount() == 3
# Should show all 3 rows again
all_visible = all(
device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex())
for i in range(device_table_model.rowCount())
)
assert all_visible
###
# Test DeviceTableView
###
@pytest.fixture
def device_table_view(qtbot):
"""Fixture to create a DeviceTableView instance."""
view = DeviceTableView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_device_table_view_initialization(qtbot, device_table_view):
"""Test the DeviceTableView search method."""
# Check that the search input fields are properly initialized and connected
qtbot.keyClicks(device_table_view.search_input, "zebra")
qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000)
qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000)
# Check table setup
# header
header = device_table_view.table.horizontalHeader()
assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags
assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description
# table selection
assert (
device_table_view.table.selectionBehavior()
== QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
)
assert (
device_table_view.table.selectionMode()
== QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
)
def test_device_table_theme_update(device_table_view):
"""Test DeviceTableView apply_theme method."""
# Check apply theme propagates
with (
mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply,
mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated,
):
device_table_view.apply_theme("dark")
mock_apply.assert_called_once_with("dark")
mock_validated.assert_called_once_with("dark")
def test_device_table_view_updates(device_table_view):
"""Test DeviceTableView methods that update the view and model."""
# Test theme update triggered..
cfgs = [
{"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True},
{"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False},
{"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True},
]
with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize:
# Should be called once for rowsInserted
device_table_view.set_device_config(cfgs)
assert device_table_view.get_device_config() == cfgs
mock_autosize.assert_called_once()
# Update validation status, should be called again
device_table_view.update_device_validation("test_device", ValidationStatus.VALID)
assert mock_autosize.call_count == 2
# Remove a device, should triggere also a _request_autosize_columns call
device_table_view.remove_device_configs([cfgs[0]])
assert device_table_view.get_device_config() == cfgs[1:]
assert mock_autosize.call_count == 3
# Remove one device manually
device_table_view.remove_device("another_device") # Should remove the last device
assert device_table_view.get_device_config() == cfgs[2:]
assert mock_autosize.call_count == 4
# Reset the model should call it once again
device_table_view.clear_device_configs()
assert mock_autosize.call_count == 5
assert device_table_view.get_device_config() == []
def test_device_table_view_get_help_md(device_table_view):
"""Test DeviceTableView get_help_md method."""
with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at:
mock_index_at.isValid = mock.MagicMock(return_value=True)
with mock.patch.object(device_table_view, "_model") as mock_model:
mock_model.headerData = mock.MagicMock(side_effect=["softTrig"])
# Second call is True, should return the corresponding help md
assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"]

View File

@@ -1,224 +0,0 @@
"""Unit tests for the device manager view"""
# pylint: disable=protected-access,redefined-outer-name
from unittest import mock
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
ConfigChoiceDialog,
DeviceManagerView,
)
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
@pytest.fixture
def dm_view(qtbot):
"""Fixture for DeviceManagerView."""
widget = DeviceManagerView()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def config_choice_dialog(qtbot, dm_view):
"""Fixture for ConfigChoiceDialog."""
dialog = ConfigChoiceDialog(dm_view)
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
yield dialog
def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog):
"""Test the configuration choice dialog."""
assert config_choice_dialog is not None
assert config_choice_dialog.parent() == dm_view
# Test dialog components
with (
mock.patch.object(config_choice_dialog, "accept") as mock_accept,
mock.patch.object(config_choice_dialog, "reject") as mock_reject,
):
# Replace
qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.REPLACE
# Add
qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.ADD
# Cancel
qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton)
mock_accept.assert_not_called()
mock_reject.assert_called_once()
assert config_choice_dialog.result() == config_choice_dialog.CANCEL
class TestDeviceManagerViewInitialization:
"""Test class for DeviceManagerView initialization and basic components."""
def test_dock_manager_initialization(self, dm_view):
"""Test that the QtAds DockManager is properly initialized."""
assert dm_view.dock_manager is not None
assert dm_view.dock_manager.centralWidget() is not None
def test_central_widget_is_device_table_view(self, dm_view):
"""Test that the central widget is DeviceTableView."""
central_widget = dm_view.dock_manager.centralWidget().widget()
assert isinstance(central_widget, DeviceTableView)
assert central_widget is dm_view.device_table_view
def test_dock_widgets_exist(self, dm_view):
"""Test that all required dock widgets are created."""
dock_widgets = dm_view.dock_manager.dockWidgets()
# Check that we have the expected number of dock widgets
assert len(dock_widgets) >= 4
# Check for specific widget types
widget_types = [dock.widget().__class__ for dock in dock_widgets]
assert DMConfigView in widget_types
assert DMOphydTest in widget_types
assert DocstringView in widget_types
def test_toolbar_initialization(self, dm_view):
"""Test that the toolbar is properly initialized with expected bundles."""
assert dm_view.toolbar is not None
assert "IO" in dm_view.toolbar.bundles
assert "Table" in dm_view.toolbar.bundles
def test_toolbar_components_exist(self, dm_view):
"""Test that all expected toolbar components exist."""
expected_components = [
"load",
"save_to_disk",
"load_redis",
"update_config_redis",
"reset_composed",
"add_device",
"remove_device",
"rerun_validation",
]
for component in expected_components:
assert dm_view.toolbar.components.exists(component)
def test_signal_connections(self, dm_view):
"""Test that signals are properly connected between components."""
# Test that device_table_view signals are connected
assert dm_view.device_table_view.selected_devices is not None
assert dm_view.device_table_view.device_configs_changed is not None
# Test that ophyd_test_view signals are connected
assert dm_view.ophyd_test_view.device_validated is not None
class TestDeviceManagerViewIOBundle:
"""Test class for DeviceManagerView IO bundle actions."""
def test_io_bundle_exists(self, dm_view):
"""Test that IO bundle exists and contains expected actions."""
assert "IO" in dm_view.toolbar.bundles
io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"]
for action in io_actions:
assert dm_view.toolbar.components.exists(action)
def test_load_file_action_triggered(self, tmp_path, dm_view):
"""Test load file action trigger mechanism."""
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load"
) as mock_yaml_load,
mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog,
):
mock_yaml_data = {"device1": {"param1": "value1"}}
mock_yaml_load.return_value = mock_yaml_data
# Setup dialog mock
dm_view.toolbar.components._components["load"].action.action.triggered.emit()
mock_yaml_load.assert_called_once_with(tmp_path)
mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}])
def test_save_config_to_file(self, tmp_path, dm_view):
"""Test saving config to file."""
yaml_path = tmp_path / "test_save.yaml"
mock_config = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path),
mock.patch.object(
dm_view.device_table_view, "get_device_config", return_value=mock_config
),
):
dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit()
assert yaml_path.exists()
class TestDeviceManagerViewTableBundle:
"""Test class for DeviceManagerView Table bundle actions."""
def test_table_bundle_exists(self, dm_view):
"""Test that Table bundle exists and contains expected actions."""
assert "Table" in dm_view.toolbar.bundles
table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"]
for action in table_actions:
assert dm_view.toolbar.components.exists(action)
@mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question"
)
def test_reset_composed_view(self, mock_question, dm_view):
"""Test reset composed view when user confirms."""
with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear:
mock_question.return_value = QMessageBox.StandardButton.Yes
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_called_once()
mock_clear.reset_mock()
mock_question.return_value = QMessageBox.StandardButton.No
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_not_called()
def test_add_device_action_connected(self, dm_view):
"""Test add device action opens dialog correctly."""
with mock.patch.object(dm_view, "_add_device_action") as mock_add:
dm_view.toolbar.components._components["add_device"].action.action.triggered.emit()
mock_add.assert_called_once()
def test_remove_device_action(self, dm_view):
"""Test remove device action."""
with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove:
dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit()
mock_remove.assert_called_once()
def test_rerun_device_validation(self, dm_view):
"""Test rerun device validation action."""
cfgs = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change,
mock.patch.object(
dm_view.device_table_view.table, "selected_configs", return_value=cfgs
),
):
dm_view.toolbar.components._components[
"rerun_validation"
].action.action.triggered.emit()
mock_change.assert_called_once_with(cfgs, True, True)

View File

@@ -1,405 +0,0 @@
from unittest import mock
import pytest
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.guided_tour import GuidedTour
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@pytest.fixture
def main_window(qtbot):
"""Create a main window for testing."""
window = QWidget()
window.resize(800, 600)
qtbot.addWidget(window)
return window
@pytest.fixture
def guided_help(main_window):
"""Create a GuidedTour instance for testing."""
return GuidedTour(main_window, enforce_visibility=False)
@pytest.fixture
def test_widget(main_window):
"""Create a test widget."""
widget = QWidget(main_window)
widget.resize(100, 50)
widget.show()
return widget
class DummyWidget(QWidget):
"""A dummy widget for testing purposes."""
def isVisible(self) -> bool:
"""Override isVisible to always return True for testing."""
return True
class TestGuidedTour:
"""Test the GuidedTour class core functionality."""
def test_initialization(self, guided_help):
"""Test GuidedTour is properly initialized."""
assert guided_help.main_window is not None
assert guided_help._registered_widgets == {}
assert guided_help._tour_steps == []
assert guided_help._current_index == 0
assert guided_help._active is False
def test_register_widget(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test widget registration creates weak references."""
widget_id = guided_help.register_widget(
widget=test_widget, text="Test widget", title="TestWidget"
)
assert widget_id in guided_help._registered_widgets
registered = guided_help._registered_widgets[widget_id]
assert registered["text"] == "Test widget"
assert registered["title"] == "TestWidget"
# Check that widget_ref is callable (weak reference)
assert callable(registered["widget_ref"])
# Check that we can dereference the weak reference
assert registered["widget_ref"]() is test_widget
def test_register_widget_auto_name(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test widget registration with automatic naming."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
registered = guided_help._registered_widgets[widget_id]
assert registered["title"] == "QWidget"
def test_create_tour_valid_ids(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test creating tour with valid widget IDs."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
result = guided_help.create_tour([widget_id])
assert result is True
assert len(guided_help._tour_steps) == 1
assert guided_help._tour_steps[0]["text"] == "Test widget"
def test_create_tour_invalid_ids(self, guided_help: GuidedTour):
"""Test creating tour with invalid widget IDs."""
result = guided_help.create_tour(["invalid_id"])
assert result is False
assert len(guided_help._tour_steps) == 0
def test_start_tour_no_steps(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test starting tour with no steps will add all registered widgets."""
# Register a widget
guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.start_tour()
assert guided_help._active is True
assert guided_help._current_index == 0
assert len(guided_help._tour_steps) == 1
def test_start_tour_success(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test successful tour start."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.create_tour([widget_id])
guided_help.start_tour()
assert guided_help._active is True
assert guided_help._current_index == 0
assert guided_help.overlay is not None
def test_stop_tour(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test stopping a tour."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.start_tour()
guided_help.stop_tour()
assert guided_help._active is False
def test_next_step(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test moving to next step."""
widget1 = DummyWidget(test_widget)
widget2 = DummyWidget(test_widget)
guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1")
guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2")
guided_help.start_tour()
assert guided_help._current_index == 0
guided_help.next_step()
assert guided_help._current_index == 1
def test_next_step_finish_tour(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test next step on last step finishes tour."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.start_tour()
guided_help.next_step()
assert guided_help._active is False
def test_prev_step(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test moving to previous step."""
widget1 = DummyWidget(test_widget)
widget2 = DummyWidget(test_widget)
guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1")
guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2")
guided_help.start_tour()
guided_help.next_step()
assert guided_help._current_index == 1
guided_help.prev_step()
assert guided_help._current_index == 0
def test_get_registered_widgets(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test getting registered widgets."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
registered = guided_help.get_registered_widgets()
assert widget_id in registered
assert registered[widget_id]["text"] == "Test widget"
def test_clear_registrations(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test clearing all registrations."""
guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.clear_registrations()
assert len(guided_help._registered_widgets) == 0
assert len(guided_help._tour_steps) == 0
def test_weak_reference_main_window(self, main_window: QWidget):
"""Test that main window is stored as weak reference."""
guided_help = GuidedTour(main_window)
# Should be able to get main window through weak reference
assert guided_help.main_window is not None
assert guided_help.main_window == main_window
def test_complete_tour_flow(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test complete tour workflow."""
# Create widgets
widget1 = DummyWidget(test_widget)
widget2 = DummyWidget(test_widget)
# Register widgets
id1 = guided_help.register_widget(widget=widget1, text="First widget", title="Widget1")
id2 = guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2")
# Create and start tour
guided_help.start_tour()
assert guided_help._active is True
assert guided_help._current_index == 0
# Move through tour
guided_help.next_step()
assert guided_help._current_index == 1
# Finish tour
guided_help.next_step()
assert guided_help._active is False
def test_finish_button_on_last_step(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test that the Next button changes to Finish on the last step."""
widget1 = DummyWidget(test_widget)
widget2 = DummyWidget(test_widget)
guided_help.register_widget(widget=widget1, text="First widget", title="Widget1")
guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2")
guided_help.start_tour()
overlay = guided_help.overlay
assert overlay is not None
# First step should show "Next"
assert "Next" in overlay.next_btn.text()
# Navigate to last step
guided_help.next_step()
# Last step should show "Finish"
assert "Finish" in overlay.next_btn.text()
def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test that step counter is properly displayed."""
widget1 = DummyWidget(test_widget)
widget2 = DummyWidget(test_widget)
guided_help.register_widget(widget=widget1, text="First widget", title="Widget1")
guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2")
guided_help.start_tour()
overlay = guided_help.overlay
assert overlay is not None
assert overlay.step_label.text() == "Step 1 of 2"
def test_register_expandable_menu_action(self, qtbot):
"""Ensure toolbar menu actions can be registered directly."""
window = QWidget()
layout = QVBoxLayout(window)
toolbar = ModularToolBar(parent=window)
layout.addWidget(toolbar)
qtbot.addWidget(window)
tools_action = ExpandableMenuAction(
label="Tools ",
actions={
"notes": MaterialIconAction(
icon_name="note_add", tooltip="Add note", filled=True, parent=window
)
},
)
toolbar.components.add_safe("menu_tools", tools_action)
bundle = toolbar.new_bundle("menu_tools")
bundle.add_action("menu_tools")
toolbar.show_bundles(["menu_tools"])
guided = GuidedTour(window, enforce_visibility=False)
guided.register_widget(widget=tools_action, text="Toolbar tools menu")
guided.start_tour()
assert guided._active is True
@mock.patch("bec_widgets.utils.guided_tour.logger")
def test_error_handling(self, mock_logger, guided_help):
"""Test error handling and logging."""
# Test with invalid step ID
result = guided_help.create_tour(["invalid_id"])
assert result is False
mock_logger.error.assert_called()
def test_memory_safety_widget_deletion(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test memory safety when widget is deleted."""
widget = QWidget(test_widget)
# Register widget
widget_id = guided_help.register_widget(widget=widget, text="Test widget")
# Verify weak reference works
registered = guided_help._registered_widgets[widget_id]
assert registered["widget_ref"]() is widget
# Delete widget
widget.close()
widget.setParent(None)
del widget
# The weak reference should now return None
# This tests that our weak reference implementation is working
assert widget_id in guided_help._registered_widgets
registered = guided_help._registered_widgets[widget_id]
assert registered["widget_ref"]() is None
def test_unregister_widget(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test unregistering a widget."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
# Unregister the widget
guided_help.unregister_widget(widget_id)
assert widget_id not in guided_help._registered_widgets
def test_unregister_nonexistent_widget(self, guided_help: GuidedTour):
"""Test unregistering a widget that does not exist."""
# Should not raise an error
assert guided_help.unregister_widget("nonexistent_id") is False
def test_unregister_widget_removes_from_tour(
self, guided_help: GuidedTour, test_widget: QWidget
):
"""Test that unregistering a widget also removes it from the tour steps."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.create_tour([widget_id])
# Unregister the widget
guided_help.unregister_widget(widget_id)
# The tour steps should no longer contain the unregistered widget
assert len(guided_help._tour_steps) == 0
def test_unregister_widget_during_tour_raises(
self, guided_help: GuidedTour, test_widget: QWidget
):
"""Test that unregistering a widget during an active tour raises an error."""
widget_id = guided_help.register_widget(widget=test_widget, text="Test widget")
guided_help.start_tour()
with pytest.raises(RuntimeError):
guided_help.unregister_widget(widget_id)
def test_register_lambda_function(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test registering a lambda function as a widget."""
widget_id = guided_help.register_widget(
widget=lambda: (test_widget, "test text"), text="Lambda widget", title="LambdaWidget"
)
assert widget_id in guided_help._registered_widgets
registered = guided_help._registered_widgets[widget_id]
assert registered["text"] == "Lambda widget"
assert registered["title"] == "LambdaWidget"
# Check that widget_ref is callable (weak reference)
assert callable(registered["widget_ref"])
# Check that we can dereference the weak reference
assert registered["widget_ref"]()[0] is test_widget
assert registered["widget_ref"]()[1] == "test text"
def test_register_widget_local_function(self, guided_help: GuidedTour, test_widget: QWidget):
"""Test registering a local function as a widget."""
def local_widget_function():
return test_widget, "local text"
widget_id = guided_help.register_widget(
widget=local_widget_function, text="Local function widget", title="LocalWidget"
)
assert widget_id in guided_help._registered_widgets
registered = guided_help._registered_widgets[widget_id]
assert registered["text"] == "Local function widget"
assert registered["title"] == "LocalWidget"
# Check that widget_ref is callable (weak reference)
assert callable(registered["widget_ref"])
# Check that we can dereference the weak reference
assert registered["widget_ref"]()[0] is test_widget
assert registered["widget_ref"]()[1] == "local text"
def test_text_accepts_html_content(self, guided_help: GuidedTour, test_widget: QWidget, qtbot):
"""Test that registered text can contain HTML content."""
html_text = (
"<b>Bold Text</b> with <i>Italics</i> and a <a href='https://example.com'>link</a>."
)
widget_id = guided_help.register_widget(
widget=test_widget, text=html_text, title="HTMLWidget"
)
assert widget_id in guided_help._registered_widgets
registered = guided_help._registered_widgets[widget_id]
assert registered["text"] == html_text
def test_overlay_painter(self, guided_help: GuidedTour, test_widget: QWidget, qtbot):
"""
Test that the overlay painter works without errors.
While we cannot directly test the visual output, we can ensure
that calling the paintEvent does not raise exceptions.
"""
widget_id = guided_help.register_widget(
widget=test_widget, text="Test widget for overlay", title="OverlayWidget"
)
widget = guided_help._registered_widgets[widget_id]["widget_ref"]()
with mock.patch.object(widget, "isVisible", return_value=True):
guided_help.start_tour()
guided_help.overlay.paintEvent(None) # Force paint event to render text
qtbot.wait(300) # Wait for rendering

View File

@@ -1,12 +1,9 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@@ -82,51 +79,3 @@ def test_help_inspector_escape_key(qtbot, help_inspector):
assert not help_inspector._active
assert not help_inspector._button.isChecked()
assert QtWidgets.QApplication.overrideCursor() is None
def test_help_inspector_event_filter(help_inspector, abort_button):
"""Test the event filter of the HelpInspector."""
# Test nothing happens when not active
obj = mock.MagicMock(spec=QtWidgets.QWidget)
event = mock.MagicMock(spec=QtCore.QEvent)
assert help_inspector._active is False
with mock.patch.object(
QtWidgets.QWidget, "eventFilter", return_value=False
) as super_event_filter:
help_inspector.eventFilter(obj, event) # should do nothing and return False
super_event_filter.assert_called_once_with(obj, event)
super_event_filter.reset_mock()
help_inspector._active = True
with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle:
# Key press Escape
event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress)
event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Click on itself
event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress)
event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton)
event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1))
with mock.patch.object(
help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button]
):
# Return for self call
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Run Callback for abort_button
callback_data = []
def _my_callback(widget):
callback_data.append(widget)
help_inspector.register_callback(_my_callback)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_not_called()
assert len(callback_data) == 1
assert callback_data[0] == abort_button
callback_data.clear()

View File

@@ -1,9 +1,7 @@
import os
from pathlib import Path
from unittest import mock
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -36,423 +34,3 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir):
):
ide_explorer._add_local_script()
assert os.path.exists(os.path.join(tmpdir, "test_file.py"))
def test_shared_scripts_section_with_files(ide_explorer, tmpdir):
"""Test that shared scripts section is created when plugin directory has files"""
# Create dummy shared script files
shared_scripts_dir = tmpdir.mkdir("shared_scripts")
shared_scripts_dir.join("shared_script1.py").write("# Shared script 1")
shared_scripts_dir.join("shared_script2.py").write("# Shared script 2")
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_scripts_dir)
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should have both Local and Shared sections
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_macros_section_with_files(ide_explorer, tmpdir):
"""Test that shared macros section is created when plugin directory has files"""
# Create dummy shared macro files
shared_macros_dir = tmpdir.mkdir("shared_macros")
shared_macros_dir.join("shared_macro1.py").write(
"""
def shared_function1():
return "shared1"
def shared_function2():
return "shared2"
"""
)
shared_macros_dir.join("utilities.py").write(
"""
def utility_function():
return "utility"
"""
)
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_macros_dir)
ide_explorer.clear()
ide_explorer.sections = ["macros"]
macros_section = ide_explorer.main_explorer.get_section("MACROS")
assert macros_section is not None
# Should have both Local and Shared sections
local_section = macros_section.content_widget.get_section("Local")
shared_section = macros_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer):
"""Test that shared sections are not added when plugin directories don't exist"""
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = None
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir):
"""Test that shared sections are not added when plugin directory doesn't exist on disk"""
ide_explorer.clear()
# Return a path that doesn't exist
nonexistent_path = str(tmpdir.join("nonexistent"))
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = nonexistent_path
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section since directory doesn't exist
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
@pytest.mark.parametrize(
"slot, signal, file_name,scope",
[
(
"_emit_file_open_scripts_local",
"file_open_requested",
"example_script.py",
"scripts/local",
),
(
"_emit_file_preview_scripts_local",
"file_preview_requested",
"example_macro.py",
"scripts/local",
),
(
"_emit_file_open_scripts_shared",
"file_open_requested",
"example_script.py",
"scripts/shared",
),
(
"_emit_file_preview_scripts_shared",
"file_preview_requested",
"example_macro.py",
"scripts/shared",
),
],
)
def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope):
"""Test that the correct signals are emitted when files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(file_name)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_name, scope)]
@pytest.mark.parametrize(
"slot, signal, func_name, file_path,scope",
[
(
"_emit_file_open_macros_local",
"file_open_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_preview_macros_local",
"file_preview_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_open_macros_shared",
"file_open_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
(
"_emit_file_preview_macros_shared",
"file_preview_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
],
)
def test_ide_explorer_file_signals_macros(
ide_explorer, qtbot, slot, signal, func_name, file_path, scope
):
"""Test that the correct signals are emitted when macro files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(func_name, file_path)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_path, scope)]
def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro through the UI"""
# Create macros section first
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("test_macro_function", True),
):
ide_explorer._add_local_macro()
# Check that the macro file was created
expected_file = os.path.join(tmpdir, "test_macro_function.py")
assert os.path.exists(expected_file)
# Check that the file contains the expected function
with open(expected_file, "r") as f:
content = f.read()
assert "def test_macro_function():" in content
assert "test_macro_function macro" in content
def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro with invalid function name"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Test with invalid function name (starts with number)
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("123invalid", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning"
) as mock_warning,
):
ide_explorer._add_local_macro()
# Should show warning message
mock_warning.assert_called_once()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro when file already exists"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Create an existing file
existing_file = Path(tmpdir) / "existing_macro.py"
existing_file.write_text("# Existing macro")
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("existing_macro", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes,
) as mock_question,
):
ide_explorer._add_local_macro()
# Should ask for overwrite confirmation
mock_question.assert_called_once()
# File should be overwritten with new content
with open(existing_file, "r") as f:
content = f.read()
assert "def existing_macro():" in content
def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir):
"""Test cancelling the add local macro dialog"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# User cancels the dialog
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("", False), # User cancelled
):
ide_explorer._add_local_macro()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_reload_macros_success(ide_explorer, qtbot):
"""Test successful macro reloading"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock the client and macros
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information"
) as mock_info:
ide_explorer._reload_macros()
# Should call load_all_user_macros
mock_macros.load_all_user_macros.assert_called_once()
# Should show success message
mock_info.assert_called_once()
assert "successfully" in mock_info.call_args[0][2]
def test_ide_explorer_reload_macros_error(ide_explorer, qtbot):
"""Test macro reloading when an error occurs"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock client with macros that raises an exception
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_macros.load_all_user_macros.side_effect = Exception("Test error")
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical"
) as mock_critical:
ide_explorer._reload_macros()
# Should show error message
mock_critical.assert_called_once()
assert "Failed to reload macros" in mock_critical.call_args[0][2]
def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir):
"""Test refreshing a local macro file"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Create a test macro file
macro_file = Path(tmpdir) / "test_macro.py"
macro_file.write_text("def test_function(): pass")
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
ide_explorer.refresh_macro_file(str(macro_file))
# Should call refresh_file_item with the file path
mock_refresh.assert_called_once_with(str(macro_file))
def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir):
"""Test refreshing a macro file that doesn't match any directory"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Try to refresh a file that's not in any macro directory
unrelated_file = "/some/other/path/unrelated.py"
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
ide_explorer.refresh_macro_file(unrelated_file)
# Should not call refresh_file_item
mock_refresh.assert_not_called()
def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot):
"""Test refreshing a macro file when no macro sections exist"""
ide_explorer.clear()
# Don't add macros section
# Should handle gracefully without error
ide_explorer.refresh_macro_file("/some/path/test.py")
# Test passes if no exception is raised

View File

@@ -29,14 +29,6 @@ def roi_tree(qtbot, image_widget):
yield tree
@pytest.fixture
def compact_roi_tree(qtbot, image_widget):
tree = create_widget(
qtbot, ROIPropertyTree, image_widget=image_widget, compact=True, compact_color="#00BCD4"
)
yield tree
def test_initialization(roi_tree, image_widget):
"""Test that the widget initializes correctly with the right components."""
# Check the widget has the right structure
@@ -439,120 +431,3 @@ def test_cleanup_disconnect_signals(roi_tree, image_widget):
# Verify that the tree item was not updated
assert item.text(roi_tree.COL_ROI) == initial_name
assert item.child(2).text(roi_tree.COL_PROPS) == initial_coord
def test_compact_initialization_minimal_toolbar(compact_roi_tree):
assert compact_roi_tree.compact is True
assert compact_roi_tree.tree is None
# Draw actions exist
assert compact_roi_tree.toolbar.components.get_action("roi_rectangle")
assert compact_roi_tree.toolbar.components.get_action("roi_circle")
assert compact_roi_tree.toolbar.components.get_action("roi_ellipse")
# Full-mode actions are absent
import pytest
with pytest.raises(KeyError):
compact_roi_tree.toolbar.components.get_action("expand_toggle")
with pytest.raises(KeyError):
compact_roi_tree.toolbar.components.get_action("lock_unlock_all")
with pytest.raises(KeyError):
compact_roi_tree.toolbar.components.get_action("roi_tree_cmap")
assert not hasattr(compact_roi_tree, "lock_all_action")
def test_compact_single_roi_enforced_programmatic(compact_roi_tree, image_widget):
# Add first ROI
roi1 = image_widget.add_roi(kind="rect", name="r1")
assert len(image_widget.roi_controller.rois) == 1
assert roi1.line_color == "#00BCD4"
# Add second ROI; the first should be removed automatically
roi2 = image_widget.add_roi(kind="circle", name="c1")
rois = image_widget.roi_controller.rois
assert len(rois) == 1
assert rois[0] is roi2
from bec_widgets.widgets.plots.roi.image_roi import CircularROI
assert isinstance(rois[0], CircularROI)
assert rois[0].line_color == "#00BCD4"
def test_compact_add_roi_from_toolbar_single_enforced(qtbot, compact_roi_tree, image_widget):
# Ensure view is ready
plot_item = image_widget.plot_item
view = plot_item.vb.scene().views()[0]
qtbot.waitExposed(view)
# Activate rectangle drawing
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
rect_action.setChecked(True)
# Draw rectangle
start_pos = QPointF(10, 10)
end_pos = QPointF(50, 40)
start_scene = plot_item.vb.mapViewToScene(start_pos)
end_scene = plot_item.vb.mapViewToScene(end_pos)
start_widget = view.mapFromScene(start_scene)
end_widget = view.mapFromScene(end_scene)
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
qtbot.mouseMove(view.viewport(), pos=end_widget)
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
qtbot.wait(100)
rois = image_widget.roi_controller.rois
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
assert len(rois) == 1
assert isinstance(rois[0], RectangularROI)
assert rois[0].line_color == "#00BCD4"
# Now draw a circle; rectangle should be removed automatically
rect_action.setChecked(False)
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
circle_action.setChecked(True)
start_pos = QPointF(20, 20)
end_pos = QPointF(40, 40)
start_scene = plot_item.vb.mapViewToScene(start_pos)
end_scene = plot_item.vb.mapViewToScene(end_pos)
start_widget = view.mapFromScene(start_scene)
end_widget = view.mapFromScene(end_scene)
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
qtbot.mouseMove(view.viewport(), pos=end_widget)
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
qtbot.wait(100)
rois = image_widget.roi_controller.rois
assert len(rois) == 1
assert isinstance(rois[0], CircularROI)
assert rois[0].line_color == "#00BCD4"
def test_compact_draw_mode_toggle(compact_roi_tree):
# Initially no draw mode
assert compact_roi_tree._roi_draw_mode is None
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
# Toggle rect on
rect_action.toggle()
assert compact_roi_tree._roi_draw_mode == "rect"
assert rect_action.isChecked()
assert not circle_action.isChecked()
# Toggle circle on; rect should toggle off
circle_action.toggle()
assert compact_roi_tree._roi_draw_mode == "circle"
assert circle_action.isChecked()
assert not rect_action.isChecked()
# Toggle circle off → none
circle_action.toggle()
assert compact_roi_tree._roi_draw_mode is None
assert not rect_action.isChecked()
assert not circle_action.isChecked()

View File

@@ -1,548 +0,0 @@
"""
Unit tests for the MacroTreeWidget.
"""
from pathlib import Path
import pytest
from qtpy.QtCore import QEvent, QModelIndex, Qt
from qtpy.QtGui import QMouseEvent
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
@pytest.fixture
def temp_macro_files(tmpdir):
"""Create temporary macro files for testing."""
macro_dir = Path(tmpdir) / "macros"
macro_dir.mkdir()
# Create a simple macro file with functions
macro_file1 = macro_dir / "test_macros.py"
macro_file1.write_text(
'''
def test_macro_function():
"""A test macro function."""
return "test"
def another_function(param1, param2):
"""Another function with parameters."""
return param1 + param2
class TestClass:
"""This class should be ignored."""
def method(self):
pass
'''
)
# Create another macro file
macro_file2 = macro_dir / "utils_macros.py"
macro_file2.write_text(
'''
def utility_function():
"""A utility function."""
pass
def deprecated_function():
"""Old function."""
return None
'''
)
# Create a file with no functions (should be ignored)
empty_file = macro_dir / "empty.py"
empty_file.write_text(
"""
# Just a comment
x = 1
y = 2
"""
)
# Create a file starting with underscore (should be ignored)
private_file = macro_dir / "_private.py"
private_file.write_text(
"""
def private_function():
return "private"
"""
)
# Create a file with syntax errors
error_file = macro_dir / "error_file.py"
error_file.write_text(
"""
def broken_function(
# Missing closing parenthesis and colon
pass
"""
)
return macro_dir
@pytest.fixture
def macro_tree(qtbot, temp_macro_files):
"""Create a MacroTreeWidget with test macro files."""
widget = MacroTreeWidget()
widget.set_directory(str(temp_macro_files))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class TestMacroTreeWidgetInitialization:
"""Test macro tree widget initialization and basic functionality."""
def test_initialization(self, qtbot):
"""Test that the macro tree widget initializes correctly."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Check basic properties
assert widget.tree is not None
assert widget.model is not None
assert widget.delegate is not None
assert widget.directory is None
# Check that tree is configured properly
assert widget.tree.isHeaderHidden()
assert widget.tree.rootIsDecorated()
assert not widget.tree.editTriggers()
def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files):
"""Test setting a valid directory path."""
assert macro_tree.directory == str(temp_macro_files)
# Check that files were loaded
assert macro_tree.model.rowCount() > 0
# Should have 2 files (test_macros.py and utils_macros.py)
# empty.py and _private.py should be filtered out
expected_files = ["test_macros", "utils_macros"]
actual_files = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
actual_files.append(item.text())
# Sort for consistent comparison
actual_files.sort()
expected_files.sort()
for expected in expected_files:
assert expected in actual_files
def test_set_directory_with_invalid_path(self, qtbot):
"""Test setting an invalid directory path."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory("/nonexistent/path")
# Should handle gracefully
assert widget.directory == "/nonexistent/path"
assert widget.model.rowCount() == 0
def test_set_directory_with_none(self, qtbot):
"""Test setting directory to None."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory(None)
# Should handle gracefully
assert widget.directory is None
assert widget.model.rowCount() == 0
class TestMacroFunctionParsing:
"""Test macro function parsing and AST functionality."""
def test_extract_functions_from_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a Python file."""
test_file = temp_macro_files / "test_macros.py"
functions = macro_tree._extract_functions_from_file(test_file)
# Should extract 2 functions, not the class method
assert len(functions) == 2
assert "test_macro_function" in functions
assert "another_function" in functions
assert "method" not in functions # Class methods should be excluded
# Check function details
test_func = functions["test_macro_function"]
assert test_func["line_number"] == 2 # First function starts at line 2
assert "A test macro function" in test_func["docstring"]
def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with no functions."""
empty_file = temp_macro_files / "empty.py"
functions = macro_tree._extract_functions_from_file(empty_file)
assert len(functions) == 0
def test_extract_functions_from_invalid_file(self, macro_tree):
"""Test extracting functions from a non-existent file."""
nonexistent_file = Path("/nonexistent/file.py")
functions = macro_tree._extract_functions_from_file(nonexistent_file)
assert len(functions) == 0
def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with syntax errors."""
error_file = temp_macro_files / "error_file.py"
functions = macro_tree._extract_functions_from_file(error_file)
# Should return empty dict on syntax error
assert len(functions) == 0
def test_create_file_item(self, macro_tree, temp_macro_files):
"""Test creating a file item from a Python file."""
test_file = temp_macro_files / "test_macros.py"
file_item = macro_tree._create_file_item(test_file)
assert file_item is not None
assert file_item.text() == "test_macros"
assert file_item.rowCount() == 2 # Should have 2 function children
# Check file data
file_data = file_item.data(Qt.ItemDataRole.UserRole)
assert file_data["type"] == "file"
assert file_data["file_path"] == str(test_file)
# Check function children
func_names = []
for row in range(file_item.rowCount()):
child = file_item.child(row)
func_names.append(child.text())
# Check function data
func_data = child.data(Qt.ItemDataRole.UserRole)
assert func_data["type"] == "function"
assert func_data["file_path"] == str(test_file)
assert "function_name" in func_data
assert "line_number" in func_data
assert "test_macro_function" in func_names
assert "another_function" in func_names
def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files):
"""Test that files starting with underscore are ignored."""
private_file = temp_macro_files / "_private.py"
file_item = macro_tree._create_file_item(private_file)
assert file_item is None
def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files):
"""Test that files with no functions return None."""
empty_file = temp_macro_files / "empty.py"
file_item = macro_tree._create_file_item(empty_file)
assert file_item is None
class TestMacroTreeInteractions:
"""Test macro tree widget interactions and signals."""
def test_item_click_on_function(self, macro_tree, qtbot):
"""Test clicking on a function item."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a function item
file_item = macro_tree.model.item(0) # First file
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0) # First function
func_index = func_item.index()
# Simulate click
macro_tree._on_item_clicked(func_index)
# Check signal was emitted
assert len(macro_selected_signals) == 1
function_name, file_path = macro_selected_signals[0]
assert function_name is not None
assert file_path is not None
assert file_path.endswith(".py")
def test_item_click_on_file(self, macro_tree, qtbot):
"""Test clicking on a file item (should not emit signal)."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a file item
file_item = macro_tree.model.item(0)
if file_item:
file_index = file_item.index()
# Simulate click
macro_tree._on_item_clicked(file_index)
# Should not emit signal for file items
assert len(macro_selected_signals) == 0
def test_item_double_click_on_function(self, macro_tree, qtbot):
"""Test double-clicking on a function item."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Simulate double-click
macro_tree._on_item_double_clicked(func_index)
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
def test_hover_events(self, macro_tree, qtbot):
"""Test mouse hover events and action button visibility."""
# Get the tree view and its viewport
tree_view = macro_tree.tree
viewport = tree_view.viewport()
# Initially, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
# Find a function item to hover over
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Get the position of the function item
rect = tree_view.visualRect(func_index)
pos = rect.center()
# Simulate a mouse move event over the item
mouse_event = QMouseEvent(
QEvent.Type.MouseMove,
pos,
tree_view.mapToGlobal(pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# Send the event to the viewport
macro_tree.eventFilter(viewport, mouse_event)
qtbot.wait(100)
# Now, the hover index should be set
assert macro_tree.delegate.hovered_index.isValid()
assert macro_tree.delegate.hovered_index == func_index
# Simulate mouse leaving the viewport
leave_event = QEvent(QEvent.Type.Leave)
macro_tree.eventFilter(viewport, leave_event)
qtbot.wait(100)
# After leaving, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
def test_macro_open_action(self, macro_tree, qtbot):
"""Test the macro open action functionality."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item and set it as hovered
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Set the delegate's hovered index and current macro info
macro_tree.delegate.set_hovered_index(func_index)
func_data = func_item.data(Qt.ItemDataRole.UserRole)
macro_tree.delegate.current_macro_info = func_data
# Trigger the open action
macro_tree._on_macro_open_requested()
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
class TestMacroTreeRefresh:
"""Test macro tree refresh functionality."""
def test_refresh(self, macro_tree, temp_macro_files):
"""Test refreshing the entire tree."""
# Get initial count
initial_count = macro_tree.model.rowCount()
# Add a new macro file
new_file = temp_macro_files / "new_macros.py"
new_file.write_text(
'''
def new_function():
"""A new function."""
return "new"
'''
)
# Refresh the tree
macro_tree.refresh()
# Should have one more file
assert macro_tree.model.rowCount() == initial_count + 1
def test_refresh_file_item(self, macro_tree, temp_macro_files):
"""Test refreshing a single file item."""
# Find the test_macros.py file
test_file_path = str(temp_macro_files / "test_macros.py")
# Get initial function count
initial_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
initial_functions.append(child.text())
break
# Modify the file to add a new function
with open(test_file_path, "a") as f:
f.write(
'''
def newly_added_function():
"""A newly added function."""
return "added"
'''
)
# Refresh just this file
macro_tree.refresh_file_item(test_file_path)
# Check that the new function was added
updated_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
updated_functions.append(child.text())
break
# Should have the new function
assert len(updated_functions) == len(initial_functions) + 1
assert "newly_added_function" in updated_functions
def test_refresh_nonexistent_file(self, macro_tree):
"""Test refreshing a non-existent file."""
# Should handle gracefully without crashing
macro_tree.refresh_file_item("/nonexistent/file.py")
# Tree should remain unchanged
assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash
def test_expand_collapse_all(self, macro_tree, qtbot):
"""Test expand/collapse all functionality."""
# Initially should be expanded
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
# Items with children should be expanded after initial load
if item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
# Collapse all
macro_tree.collapse_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert not macro_tree.tree.isExpanded(item.index())
# Expand all
macro_tree.expand_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
class TestMacroItemDelegate:
"""Test the custom macro item delegate functionality."""
def test_delegate_action_management(self, qtbot):
"""Test adding and clearing delegate actions."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Should have at least one default action (open)
assert len(widget.delegate.macro_actions) >= 1
# Add a custom action
custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget)
widget.add_macro_action(custom_action.action)
# Should have the additional action
assert len(widget.delegate.macro_actions) >= 2
# Clear actions
widget.clear_actions()
# Should be empty
assert len(widget.delegate.macro_actions) == 0
def test_delegate_hover_index_management(self, qtbot):
"""Test hover index management in the delegate."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Initially no hover
assert not widget.delegate.hovered_index.isValid()
# Create a fake index
fake_index = widget.model.createIndex(0, 0)
# Set hover
widget.delegate.set_hovered_index(fake_index)
assert widget.delegate.hovered_index == fake_index
# Clear hover
widget.delegate.set_hovered_index(QModelIndex())
assert not widget.delegate.hovered_index.isValid()

View File

@@ -1,425 +0,0 @@
import os
from typing import Generator
from unittest import mock
import pytest
from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from .client_mocks import mocked_client
@pytest.fixture
def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]:
"""Create a MonacoDock for testing."""
# Mock the macros functionality
mocked_client.macros = mock.MagicMock()
mocked_client.macros._update_handler = mock.MagicMock()
mocked_client.macros._update_handler.get_macros_from_file.return_value = {}
mocked_client.macros._update_handler.get_existing_macros.return_value = {}
widget = MonacoDock(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class TestFocusEditor:
def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock):
"""Test that last_focused_editor is initially None."""
assert monaco_dock.last_focused_editor is not None
def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test setting last_focused_editor when an editor is focused."""
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
monaco_dock.open_file(str(file_path))
qtbot.wait(300) # Wait for the editor to be fully set up
assert monaco_dock.last_focused_editor is not None
def test_last_focused_editor_updates_on_focus_change(
self, qtbot, monaco_dock: MonacoDock, tmpdir
):
"""Test that last_focused_editor updates when focus changes."""
file1 = tmpdir.join("file1.py")
file1.write("print('File 1')")
file2 = tmpdir.join("file2.py")
file2.write("print('File 2')")
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1 = monaco_dock.last_focused_editor
monaco_dock.open_file(str(file2))
qtbot.wait(300)
editor2 = monaco_dock.last_focused_editor
assert editor1 != editor2
assert editor2 is not None
def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test that opening an already open file simply switches focus to it."""
file1 = tmpdir.join("file1.py")
file1.write("print('File 1')")
file2 = tmpdir.join("file2.py")
file2.write("print('File 2')")
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1 = monaco_dock.last_focused_editor
monaco_dock.open_file(str(file2))
qtbot.wait(300)
editor2 = monaco_dock.last_focused_editor
# Re-open file1
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1_again = monaco_dock.last_focused_editor
assert editor1 == editor1_again
assert editor1 != editor2
assert editor2 is not None
class TestSaveFiles:
def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving an existing file that is not a macro."""
# Create a test file
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
# Open file in Monaco dock
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Modified content')")
qtbot.wait(100)
# Verify the editor is marked as modified
assert editor_widget.modified
# Save the file
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName"
) as mock_dialog:
mock_dialog.return_value = (str(file_path), "Python files (*.py)")
monaco_dock.save_file()
qtbot.wait(100)
# Verify file was saved
saved_content = file_path.read()
assert saved_content == 'print("Modified content")\n'
# Verify editor is no longer marked as modified
assert not editor_widget.modified
def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving a file with macros scope updates macro handler."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
editor_widget.set_text("def modified_function(): pass")
qtbot.wait(100)
# Mock macro validation to return True (valid)
with mock.patch.object(monaco_dock, "_validate_macros", return_value=True):
# Mock file dialog to avoid opening actual dialog (file already exists)
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "") # User cancels
# Save the file (should save to existing file, not open dialog)
monaco_dock.save_file()
qtbot.wait(100)
# Verify macro update methods were called
monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with(
str(file_path)
)
monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with(
str(file_path)
)
def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving a macro file with invalid content shows warning."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content to invalid macro
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("exec('print(hello)')") # Invalid macro content
qtbot.wait(100)
# Mock QMessageBox to capture warning
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning"
) as mock_warning:
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "")
# Save the file
monaco_dock.save_file()
qtbot.wait(100)
# Verify validation was called and warning was shown
mock_warning.assert_called_once()
# Verify file was not saved (content should remain original)
saved_content = file_path.read()
assert saved_content == "def test_function(): pass"
def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test Save As functionality creates a new file."""
# Create initial content in editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('New file content')")
qtbot.wait(100)
# Mock QFileDialog.getSaveFileName
new_file_path = str(tmpdir.join("new_file.py"))
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (new_file_path, "Python files (*.py)")
# Save as new file
monaco_dock.save_file(force_save_as=True)
qtbot.wait(100)
# Verify new file was created
assert os.path.exists(new_file_path)
with open(new_file_path, "r", encoding="utf-8") as f:
content = f.read()
assert content == 'print("New file content")\n'
# Verify editor is no longer marked as modified
assert not editor_widget.modified
# Verify current_file was updated
assert editor_widget.current_file == new_file_path
def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test Save As automatically adds .py extension if none provided."""
# Create initial content in editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Test content')")
qtbot.wait(100)
# Mock QFileDialog.getSaveFileName to return path without extension
file_path_no_ext = str(tmpdir.join("test_file"))
expected_path = file_path_no_ext + ".py"
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (file_path_no_ext, "All files (*)")
# Save as new file
monaco_dock.save_file(force_save_as=True)
qtbot.wait(100)
# Verify file was created with .py extension
assert os.path.exists(expected_path)
assert editor_widget.current_file == expected_path
def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock):
"""Test save_file handles case when no editor is focused."""
# Set last_focused_editor to None
with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None):
# Attempt to save should not raise exception
monaco_dock.save_file()
def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir):
"""Test that macro_file_updated signal is emitted when saving macro files."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
editor_widget.set_text("def modified_function(): pass")
qtbot.wait(100)
# Connect signal to capture emission
signal_emitted = []
monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path))
# Mock file dialog to avoid opening actual dialog (file already exists)
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "")
# Save the file
monaco_dock.save_file()
qtbot.wait(100)
# Verify signal was emitted
assert len(signal_emitted) == 1
assert signal_emitted[0] == str(file_path)
def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test that closing a modified file dock asks to save changes."""
# Create a test file
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
# Open file in Monaco dock
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Modified content')")
qtbot.wait(100)
# Mock QMessageBox to simulate user clicking 'Save'
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question"
) as mock_question:
mock_question.return_value = QMessageBox.StandardButton.Yes
# Mock QFileDialog.getSaveFileName
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "Python files (*.py)")
# Close the dock; sadly, calling close() alone does not trigger the closeRequested signal
# It is only triggered if the mouse is on top of the tab close button, so we directly call the handler
monaco_dock._on_editor_close_requested(
monaco_dock.last_focused_editor, editor_widget
)
qtbot.wait(100)
# Verify file was saved
saved_content = file_path.read()
assert saved_content == 'print("Modified content")\n'
class TestSignatureHelp:
def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock):
"""Test that signature help signal is emitted correctly."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data
signature_data = {
"signatures": [
{
"label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)",
"documentation": {
"value": "Print objects to the text stream file, separated by sep and followed by end."
},
}
],
"activeSignature": 0,
"activeParameter": 0,
}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with correct markdown format
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature
assert "Print objects to the text stream file" in emitted_signature
def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help with empty signatures."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data with no signatures
signature_data = {"signatures": []}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify empty string was emitted
assert len(signature_emitted) == 1
assert signature_emitted[0] == ""
def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help when documentation is missing."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data without documentation
signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with just the function signature
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "function_name(param)" in emitted_signature
def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help when documentation is a string instead of dict."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data with string documentation
signature_data = {
"signatures": [
{"label": "function_name(param)", "documentation": "Simple string documentation"}
],
"activeSignature": 0,
}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with correct format
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "function_name(param)" in emitted_signature
assert "Simple string documentation" in emitted_signature
def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock):
"""Test that signature help is connected when creating new editors."""
# Create a new editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
# Verify the signal connection exists by checking connected signals
# We do this by mocking the signal and verifying the connection
with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler:
# Simulate signature help trigger from the editor
editor_widget.editor.signature_help_triggered.emit({"signatures": []})
qtbot.wait(100)
# Verify the handler was called
mock_handler.assert_called_once()

View File

@@ -1,20 +1,11 @@
from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
from .client_mocks import mocked_client
from .test_scan_control import available_scans_message
@pytest.fixture
def monaco_widget(qtbot, mocked_client):
widget = MonacoWidget(client=mocked_client)
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
def monaco_widget(qtbot):
widget = MonacoWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -46,75 +37,3 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot):
monaco_widget.set_text("Attempting to change text")
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000)
assert monaco_widget.get_text() == "Attempting to change text"
def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot):
"""
Test that the MonacoWidget can show the scan control dialog.
"""
with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog:
monaco_widget._show_scan_control_dialog()
mock_run_dialog.assert_called_once()
def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client):
"""
Test that the MonacoWidget can get scan control code from the dialog.
"""
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
scan_control_dialog = ScanControlDialog(client=mocked_client)
qtbot.addWidget(scan_control_dialog)
qtbot.waitExposed(scan_control_dialog)
qtbot.wait(300)
scan_control = scan_control_dialog.scan_control
scan_name = "grid_scan"
kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2}
args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20}
args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10}
mock_slot = mock.MagicMock()
scan_control.scan_args.connect(mock_slot)
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Ensure there are two rows in the arg_box
current_rows = scan_control.arg_box.count_arg_rows()
required_rows = 2
while current_rows < required_rows:
scan_control.arg_box.add_widget_bundle()
current_rows += 1
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
if widget.arg_name in kwargs:
WidgetIO.set_value(widget, kwargs[widget.arg_name])
# Set args in the UI for both rows
arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets
num_columns = len(scan_control.arg_box.inputs)
num_rows = int(len(arg_widgets) / num_columns)
assert num_rows == required_rows # We expect 2 rows for grid_scan
# Set values for first row
for i in range(num_columns):
widget = arg_widgets[i]
arg_name = widget.arg_name
if arg_name in args_row1:
WidgetIO.set_value(widget, args_row1[arg_name])
# Set values for second row
for i in range(num_columns):
widget = arg_widgets[num_columns + i] # Next row
arg_name = widget.arg_name
if arg_name in args_row2:
WidgetIO.set_value(widget, args_row2[arg_name])
scan_control_dialog.accept()
out = scan_control_dialog.get_scan_code()
expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None, metadata={'sample_name': ''})"
assert out == expected_code

View File

@@ -50,7 +50,7 @@ def positioner_box(qtbot, mocked_client):
def test_positioner_box(positioner_box):
"""Test init of positioner box"""
assert positioner_box.device == "samx"
data = positioner_box.dev["samx"].read(cached=True)
data = positioner_box.dev["samx"].read()
# Avoid check for Positioner class from BEC in _init_device
setpoint_text = positioner_box.ui.setpoint.text()

View File

@@ -29,8 +29,8 @@ def test_positioner_box_2d(positioner_box_2d):
"""Test init of 2D positioner box"""
assert positioner_box_2d.device_hor == "samx"
assert positioner_box_2d.device_ver == "samy"
data_hor = positioner_box_2d.dev["samx"].read(cached=True)
data_ver = positioner_box_2d.dev["samy"].read(cached=True)
data_hor = positioner_box_2d.dev["samx"].read()
data_ver = positioner_box_2d.dev["samy"].read()
# Avoid check for Positioner class from BEC in _init_device
setpoint_hor_text = positioner_box_2d.ui.setpoint_hor.text()

View File

@@ -505,13 +505,7 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
args, kwargs = scan_control.get_scan_parameters(bec_object=False)
assert args == ["samx", 0.0, 2.0]
assert kwargs == {
"steps": 10,
"relative": False,
"exp_time": 2.0,
"burst_at_each_point": 1,
"metadata": {"sample_name": ""},
}
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"}
@@ -563,7 +557,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans = SimpleNamespace(grid_scan=MagicMock())
with (
patch.object(scan_control, "scans", scans),
patch.object(scan_control, "get_scan_parameters", lambda: ((), {"metadata": TEST_MD})),
patch.object(scan_control, "get_scan_parameters", lambda: ((), {})),
):
scan_control.run_scan()
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)

View File

@@ -10,19 +10,22 @@ import pyqtgraph as pg
import pytest
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QSpinBox,
)
from bec_widgets.widgets.plots.plot_base import UIMode
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from tests.unit_tests.client_mocks import (
DummyData,
create_dummy_scan_item,
dap_plugin_message,
inject_scan_history,
mocked_client,
mocked_client_with_dap,
)
@@ -838,33 +841,6 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
assert fit_action.isChecked() is False
def test_show_scan_history_popup(qtbot, mocked_client):
"""
Test that show_scan_history_popup displays the scan history browser dialog
and toggles the toolbar action correctly.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
scan_action = wf.toolbar.components.get_action("scan_history").action
# Initially unchecked and no dialog
assert not scan_action.isChecked()
assert wf.scan_history_dialog is None
# Show the popup
wf.show_scan_history_popup()
# Dialog should exist and be visible, action checked
assert wf.scan_history_dialog is not None
assert wf.scan_history_dialog.isVisible()
assert scan_action.isChecked()
# The embedded widget should be the correct type
assert isinstance(wf.scan_history_widget, ScanHistoryBrowser)
# Close the dialog (triggers _scan_history_closed)
wf.scan_history_dialog.close()
# Dialog reference should be cleared and action unchecked
assert wf.scan_history_dialog is None
assert not scan_action.isChecked()
#####################################################
# The following tests are for the async dataset guard
#####################################################
@@ -1087,187 +1063,3 @@ def test_dialog_reject_real_interaction(qtbot, mocked_client):
assert wf.skip_large_dataset_warning is True
# Limit remains unchanged
assert wf.max_dataset_size_mb == 1
def test_update_with_scan_history_by_index(qtbot, mocked_client, scan_history_factory):
"""
Test that update_with_scan_history by index loads the correct historical scan.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
assert len(wf.client.history._scan_ids) == 2, "Expected two history scans"
# Do history curve plotting
wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
wf.plot(y_name="bpm4i", scan_number=2)
assert len(wf.plot_item.curves) == 2, "Expected two curves for history scans"
c1, c2 = wf.plot_item.curves
# First curve should be for hist1, second for hist2
assert c1.config.signal.name == "bpm4i"
assert c1.config.signal.entry == "bpm4i"
assert c1.config.scan_id == "hist1"
assert c1.config.scan_number == 1
assert c1.name() == "bpm4i-bpm4i-scan-1"
assert c2.config.signal.name == "bpm4i"
assert c2.config.signal.entry == "bpm4i"
assert c2.config.scan_id == "hist2"
assert c2.config.scan_number == 2
assert c2.name() == "bpm4i-bpm4i-scan-2"
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
def test_history_curve_x_modes_pre_plot(qtbot, mocked_client, scan_history_factory, mode):
"""
Test that history curves respect x_mode when set before plotting.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
wf.x_mode = mode
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
assert c.config.current_x_mode == mode
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
def test_history_curve_x_modes_post_plot(qtbot, mocked_client, scan_history_factory, mode):
"""
Test that changing x_mode after plotting history curves updates the curve on refresh.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
# Change x_mode after plotting
wf.x_mode = mode
# Refresh history curves
wf._refresh_history_curves()
assert c.config.current_x_mode == mode
def test_history_curve_incompatible_x_mode_hides_curve(qtbot, mocked_client, scan_history_factory):
"""
Test that setting an x_mode not present in stored data hides the history curve.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "nonexistent_device"
# Inject history scan for this test
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_bad", 1))
# Plot history curve
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
# Curve should be hidden due to incompatible x_mode
assert not c.isVisible()
def test_fetch_history_data_no_stored_data_raises(
qtbot, mocked_client, monkeypatch, suppress_message_box
):
"""
Test that fetching history data when stored_data_info is missing raises ValueError.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Create a dummy scan_item lacking stored_data_info
dummy_scan = SimpleNamespace(
_msg=SimpleNamespace(stored_data_info=None),
devices={},
metadata={"bec": {"scan_id": "dummy", "scan_number": 1, "scan_report_devices": []}},
)
# Force get_history_scan_item to return our dummy
monkeypatch.setattr(wf, "get_history_scan_item", lambda scan_id, scan_index: dummy_scan)
# Attempt to plot history curve should be suppressed by SafeSlot and return None
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="dummy", scan_number=1)
assert c is None
assert len(wf.curves) == 0
def test_history_curve_device_missing_returns_none(qtbot, mocked_client, scan_history_factory):
"""
If the y-device is not in stored_data_info, plot should return None.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "index"
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_dev_missing", 1))
c = wf.plot(y_name="non-existing", y_entry="non-existing", scan_id=history_msg.scan_id)
assert c is None
def test_history_curve_custom_shape_mismatch_hides_curve(
qtbot, mocked_client, scan_history_factory
):
"""
For custom x-mode, if x and y shapes mismatch, curve should be hidden.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "async_device"
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_custom_shape", 1))
# Force shape mismatch for x-data
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
assert c is not None
assert not c.isVisible()
def test_history_curve_index_mode_plots_curve(qtbot, mocked_client, scan_history_factory):
"""
Test that setting x_mode to 'index' plots and shows the history curve correctly.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "index"
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_index", 1))
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
assert c is not None
assert c.isVisible()
assert c.config.current_x_mode == "index"
def test_history_curve_timestamp_mode_plots_curve(qtbot, mocked_client, scan_history_factory):
"""
Test that setting x_mode to 'timestamp' plots and shows the history curve correctly.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "timestamp"
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_time", 1))
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
assert c is not None
assert c.isVisible()
assert c.config.current_x_mode == "timestamp"
def test_history_curve_auto_valid_uses_first_report_device(
qtbot, mocked_client, scan_history_factory
):
"""
Test that 'auto' x_mode uses the first available report device and shows the curve.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "auto"
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_auto_valid", 1))
# Plot history curve
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
assert c is not None
assert c.isVisible()
# Should have fallen back to the first scan_report_device
assert c.config.current_x_mode == "auto"
def test_history_curve_file_not_found_returns_none(qtbot, mocked_client, scan_history_factory):
"""
If the history file path does not exist, plot should return None.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "index"
# Inject a valid history message then corrupt its file_path
[history_msg] = inject_scan_history(wf, scan_history_factory, ("bad_file", 1))
history_msg.file_path = "/nonexistent/path.h5"
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
assert c is None
def test_history_curve_scan_not_found_returns_none(qtbot, mocked_client):
"""
If the requested scan_id is not in history, plot should return None.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "index"
# No history scans injected for this widget
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="unknown_scan")
assert c is None