mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-19 23:05:36 +02:00
Compare commits
90 Commits
refactor/i
...
help_inspe
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c786faaaf | |||
| e4b909cca0 | |||
| d35f802d99 | |||
| e7ba29569d | |||
| 69568cdfd0 | |||
| 44943d5d10 | |||
| c766f4b84a | |||
| bc5424df09 | |||
| 1b35b1b36e | |||
| 920e7651b5 | |||
| 9c14289719 | |||
| 040275ac8b | |||
| 20c94697dd | |||
| 5e4d2ec0ef | |||
| 8294ef2449 | |||
| 148b387019 | |||
| 028ba6a684 | |||
| f9cc01408d | |||
| fb2d8ca9d3 | |||
| b65da75f1e | |||
| 0bb693a062 | |||
| 33c4527da9 | |||
| f89b330db3 | |||
| ae7f313fad | |||
| 5d148babe5 | |||
| 63a792aed9 | |||
| f9e21153b6 | |||
| 7bead79a96 | |||
| eee0ca92a7 | |||
| 688b1242e3 | |||
| e93b13ca79 | |||
| f293f1661a | |||
| 6a6fe41f8d | |||
| 73c46d47a3 | |||
| c7cd3c60b4 | |||
| 5cfaeb9efd | |||
| ced2213e4c | |||
| 77ea92cd1a | |||
| 53a230c719 | |||
| 66581b60d1 | |||
| e618c56c11 | |||
| b26a568b57 | |||
| 95a040522f | |||
| 499b4d5615 | |||
| b5c6d93cba | |||
| d92259e8c0 | |||
| c7a0f531d0 | |||
| e89cefed97 | |||
| 14d7f1fcad | |||
| 49b9cbf553 | |||
| 1803d3dd9d | |||
| a823dd243e | |||
| 34ed0daa98 | |||
| 7c9ba024bc | |||
| 8fd091ab44 | |||
| 84b892d7f0 | |||
| 97722bdde7 | |||
| 63c599db76 | |||
| 1adabb0955 | |||
| b1d2100e05 | |||
| 4420793cf3 | |||
| d2fede00d2 | |||
| ff4025c209 | |||
| 8f5d28a276 | |||
| 1a2ec920f6 | |||
| 098f2d4f6f | |||
| 706490247b | |||
| a0e190e38d | |||
| 9aae92aa89 | |||
| 35f3caf2dd | |||
| 37191aae62 | |||
| 1feeb11ab0 | |||
| ffa22242d0 | |||
| a32751d368 | |||
| f60939d231 | |||
| fc1e514883 | |||
| 9e2d0742ca | |||
| 16073dfd6d | |||
| 410fd517c5 | |||
| a25781d8d7 | |||
| 9488923381 | |||
| ad85472698 | |||
| 77eb21ac52 | |||
| 6f43917cc3 | |||
| e45d5da032 | |||
| 74f27ec2d9 | |||
| 296b858cdd | |||
| ab8dfd3811 | |||
| b6d4d5d749 | |||
| 5a6641f0f9 |
1
.github/actions/bw_install/action.yml
vendored
1
.github/actions/bw_install/action.yml
vendored
@@ -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
|
||||
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_())
|
||||
@@ -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_())
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan 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
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -192,7 +192,6 @@ class BECWidget(BECConnector):
|
||||
Returns:
|
||||
str: The help text in markdown format.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from PySide6QtAds import *
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."]),
|
||||
}
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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_())
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan 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()
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "__", ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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__])
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user