mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
118 Commits
feat/788_s
...
refactor/i
| Author | SHA1 | Date | |
|---|---|---|---|
| b1194da8b7 | |||
| 7f488c09ef | |||
| 8776784507 | |||
| 79d64c43d5 | |||
| fa5135d2d7 | |||
| e1e0fbd390 | |||
| 4284d572ec | |||
| 96e7ca34ab | |||
| 0d0eb1d8ee | |||
| da1a3ddfc4 | |||
| 7cd4f448b5 | |||
| 752d9ba575 | |||
| 7a43111de0 | |||
| b43abfafcd | |||
| 4dc128c64a | |||
| cb233b877d | |||
| 16d88fe8df | |||
| 6b5aa74934 | |||
| 140f126560 | |||
| a57d3c6e1c | |||
| bb363d8280 | |||
| b81e92b3ee | |||
| bc77d3199e | |||
| 3522caba67 | |||
| 979de3519b | |||
| 4e38365d00 | |||
| caaa9e5d7f | |||
| c97c50ffdb | |||
| cbf9d21b7d | |||
| 8e66bae6ec | |||
| b2af9c421c | |||
| de5c2e37ed | |||
| 60c207f1fe | |||
| c609a2c9cb | |||
| 8b9295356d | |||
| e52afd569f | |||
| 91e8a72e07 | |||
| bd9db4f20e | |||
| fcd7b3d642 | |||
| 718f4564ab | |||
| 657648c833 | |||
| 99007d8cf5 | |||
| 016e1af79e | |||
| 1b7f9a3323 | |||
| 12a454f762 | |||
| d8900a579f | |||
| ca9007a6e2 | |||
| 421e6d38e4 | |||
| fb2dfff4f3 | |||
| 0cad33cbd3 | |||
| 9cd4e10408 | |||
| f7f37509b6 | |||
| 7e3de2c64e | |||
| bd9be10aaf | |||
| 5fde098974 | |||
| 97fddf0196 | |||
| 9f755997e2 | |||
| cac1066861 | |||
| 5281d380cb | |||
| 24c213a705 | |||
| f41ea592bb | |||
| a4e02460ad | |||
| a998793d00 | |||
| be812322b0 | |||
| 7676e863b2 | |||
| d467ecc5e0 | |||
| 72c86b9d6c | |||
| 4ada3085a9 | |||
| 42fc7c8568 | |||
| 7f77b9b9f4 | |||
| 03441e93d0 | |||
| 73731c8142 | |||
| 17bccb8211 | |||
| 7935b9b820 | |||
| 5895147c00 | |||
| 6f78c914b7 | |||
| c20bc00f45 | |||
| 67d50d7b68 | |||
| 3a78c1b177 | |||
| 66a29d1f07 | |||
| c72789c8fd | |||
| fa642aaf49 | |||
| 2aef05056c | |||
| e0d65b42f4 | |||
| 8c226b66c6 | |||
| 39809e88b8 | |||
| 3dba8321f2 | |||
| 0daeb389ef | |||
| bdd732b236 | |||
| d21464f98f | |||
| b02994558b | |||
| b2d6b8dac0 | |||
| 3e23ba9aa2 | |||
| daeec7e0c9 | |||
| 03fe3a83c4 | |||
| 43d49276ce | |||
| b6f8866667 | |||
| dcdf5a4a54 | |||
| 125c94cbbe | |||
| 138fd1bea7 | |||
| 63b7f92f22 | |||
| 66f01dc9a6 | |||
| d4ea9f2992 | |||
| 9b9b8ab718 | |||
| 6b1b7a6b56 | |||
| 4e96d6c53c | |||
| cbcf23965b | |||
| 24f54fc252 | |||
| 6643dc2b57 | |||
| a58fb3fa53 | |||
| 5ecdaf4121 | |||
| 77295f13f7 | |||
| faa0aa659d | |||
| 00c0232833 | |||
| 8f359f8d37 | |||
| 5a49c41a52 | |||
| e4fb126fad | |||
| bf123550e1 |
1
.github/actions/bw_install/action.yml
vendored
1
.github/actions/bw_install/action.yml
vendored
@@ -53,6 +53,7 @@ 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
|
||||
|
||||
8
.github/workflows/pytest.yml
vendored
8
.github/workflows/pytest.yml
vendored
@@ -57,6 +57,14 @@ jobs:
|
||||
id: coverage
|
||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: image-references
|
||||
path: bec_widgets/tests/reference_failures/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
226
bec_widgets/applications/main_app.py
Normal file
226
bec_widgets/applications/main_app.py
Normal file
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._show_examples = bool(show_examples)
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
# Mapping for view switching
|
||||
self._view_index: dict[str, int] = {}
|
||||
self._current_view_id: str | None = None
|
||||
self.sidebar.view_selected.connect(self._on_view_selected)
|
||||
|
||||
self._add_views()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self)
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
|
||||
def add_separator(self):
|
||||
return self.sidebar.add_separator()
|
||||
|
||||
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
|
||||
return self.sidebar.add_dark_mode_item(id=id, position=position)
|
||||
|
||||
def add_view(
|
||||
self,
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
|
||||
|
||||
"""
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
# 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)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
if id in self._view_index:
|
||||
self.sidebar.activate_item(id)
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
|
||||
)
|
||||
|
||||
# Ask current view whether we may leave
|
||||
if current_view is not None and hasattr(current_view, "on_exit"):
|
||||
may_leave = current_view.on_exit()
|
||||
if may_leave is False:
|
||||
# Veto: restore previous highlight without re-emitting selection
|
||||
if self._current_view_id is not None:
|
||||
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
|
||||
return
|
||||
|
||||
# Proceed with switch
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
self.stack.setCurrentIndex(idx)
|
||||
new_view = self.stack.widget(idx)
|
||||
self._current_view_id = vid
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Main Application")
|
||||
parser.add_argument(
|
||||
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
|
||||
)
|
||||
# Let Qt consume the remaining args
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
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())
|
||||
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
|
||||
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
ANIMATION_DURATION = 500 # ms
|
||||
|
||||
|
||||
class RevealAnimator:
|
||||
"""Animate reveal/hide for a single widget using opacity + max W/H.
|
||||
|
||||
This keeps the widget always visible to avoid jitter from setVisible().
|
||||
Collapsed state: opacity=0, maxW=0, maxH=0.
|
||||
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: QWidget,
|
||||
duration: int = ANIMATION_DURATION,
|
||||
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
|
||||
initially_revealed: bool = False,
|
||||
*,
|
||||
animate_opacity: bool = True,
|
||||
animate_width: bool = True,
|
||||
animate_height: bool = True,
|
||||
):
|
||||
self.widget = widget
|
||||
self.animate_opacity = animate_opacity
|
||||
self.animate_width = animate_width
|
||||
self.animate_height = animate_height
|
||||
# Opacity effect
|
||||
self.fx = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(self.fx)
|
||||
# Animations
|
||||
self.opacity_anim = (
|
||||
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
|
||||
)
|
||||
self.width_anim = (
|
||||
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
|
||||
)
|
||||
self.height_anim = (
|
||||
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
|
||||
)
|
||||
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
|
||||
if anim is not None:
|
||||
anim.setDuration(duration)
|
||||
anim.setEasingCurve(easing)
|
||||
# Initialize to requested state
|
||||
self.set_immediate(initially_revealed)
|
||||
|
||||
def _natural_sizes(self) -> tuple[int, int]:
|
||||
sh = self.widget.sizeHint()
|
||||
w = max(sh.width(), 1)
|
||||
h = max(sh.height(), 1)
|
||||
return w, h
|
||||
|
||||
def set_immediate(self, revealed: bool):
|
||||
"""
|
||||
Immediately set the widget to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
revealed(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
w, h = self._natural_sizes()
|
||||
if self.animate_opacity:
|
||||
self.fx.setOpacity(1.0 if revealed else 0.0)
|
||||
if self.animate_width:
|
||||
self.widget.setMaximumWidth(w if revealed else 0)
|
||||
if self.animate_height:
|
||||
self.widget.setMaximumHeight(h if revealed else 0)
|
||||
|
||||
def setup(self, reveal: bool):
|
||||
"""
|
||||
Prepare animations to transition to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
reveal(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
# Prepare animations from current state to target
|
||||
target_w, target_h = self._natural_sizes()
|
||||
if self.opacity_anim is not None:
|
||||
self.opacity_anim.setStartValue(self.fx.opacity())
|
||||
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
|
||||
if self.width_anim is not None:
|
||||
self.width_anim.setStartValue(self.widget.maximumWidth())
|
||||
self.width_anim.setEndValue(target_w if reveal else 0)
|
||||
if self.height_anim is not None:
|
||||
self.height_anim.setStartValue(self.widget.maximumHeight())
|
||||
self.height_anim.setEndValue(target_h if reveal else 0)
|
||||
|
||||
def add_to_group(self, group: QParallelAnimationGroup):
|
||||
"""
|
||||
Add the prepared animations to the given animation group.
|
||||
|
||||
Args:
|
||||
group(QParallelAnimationGroup): The animation group to add to.
|
||||
"""
|
||||
if self.opacity_anim is not None:
|
||||
group.addAnimation(self.opacity_anim)
|
||||
if self.width_anim is not None:
|
||||
group.addAnimation(self.width_anim)
|
||||
if self.height_anim is not None:
|
||||
group.addAnimation(self.height_anim)
|
||||
|
||||
def animations(self):
|
||||
"""
|
||||
Get a list of all animations (non-None) for adding to a group.
|
||||
"""
|
||||
return [
|
||||
anim
|
||||
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
|
||||
if anim is not None
|
||||
]
|
||||
358
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
358
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
@@ -0,0 +1,358 @@
|
||||
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,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
DarkModeNavItem,
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
SideBarSeparator,
|
||||
)
|
||||
|
||||
|
||||
class SideBar(QScrollArea):
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("SideBar")
|
||||
|
||||
# private attributes
|
||||
self._is_expanded = False
|
||||
self._collapsed_width = collapsed_width
|
||||
self._expanded_width = expanded_width
|
||||
self._anim_duration = anim_duration
|
||||
|
||||
# containers
|
||||
self.components = {}
|
||||
self._item_opts: dict[str, dict] = {}
|
||||
|
||||
# Scroll area properties
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(self._collapsed_width)
|
||||
|
||||
# Content widget holding buttons for switching views
|
||||
self.content = QWidget(self)
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(4)
|
||||
self.setWidget(self.content)
|
||||
|
||||
# Track active navigation item
|
||||
self._active_id = None
|
||||
|
||||
# Top row with title and toggle button
|
||||
self.toggle_row = QWidget(self)
|
||||
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
|
||||
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setObjectName("TopTitle")
|
||||
self.title_label.setStyleSheet("font-weight: 600;")
|
||||
self.title_fx = QGraphicsOpacityEffect(self.title_label)
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
self.toggle.setIcon(material_icon("keyboard_arrow_right", icon_type=QIcon))
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
|
||||
# To push the content up always
|
||||
self._bottom_spacer = QtWidgets.QSpacerItem(
|
||||
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
|
||||
# Add core widgets to layout
|
||||
self.content_layout.addWidget(self.toggle_row)
|
||||
self.content_layout.addItem(self._bottom_spacer)
|
||||
|
||||
# Animations
|
||||
self.width_anim = QPropertyAnimation(self, b"bar_width")
|
||||
self.width_anim.setDuration(self._anim_duration)
|
||||
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
|
||||
self.title_anim.setDuration(self._anim_duration)
|
||||
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.group = QParallelAnimationGroup(self)
|
||||
self.group.addAnimation(self.width_anim)
|
||||
self.group.addAnimation(self.title_anim)
|
||||
self.group.finished.connect(self._on_anim_finished)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
@SafeProperty(int)
|
||||
def bar_width(self) -> int:
|
||||
"""
|
||||
Get the current width of the side bar.
|
||||
|
||||
Returns:
|
||||
int: The current width of the side bar.
|
||||
"""
|
||||
return self.width()
|
||||
|
||||
@bar_width.setter
|
||||
def bar_width(self, width: int):
|
||||
"""
|
||||
Set the width of the side bar.
|
||||
|
||||
Args:
|
||||
width(int): The new width of the side bar.
|
||||
"""
|
||||
self.setFixedWidth(width)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def is_expanded(self) -> bool:
|
||||
"""
|
||||
Check if the side bar is expanded.
|
||||
|
||||
Returns:
|
||||
bool: True if the side bar is expanded, False otherwise.
|
||||
"""
|
||||
return self._is_expanded
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self):
|
||||
"""
|
||||
Toggle the expansion state of the side bar.
|
||||
"""
|
||||
self._is_expanded = not self._is_expanded
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
icon_type=QIcon,
|
||||
)
|
||||
)
|
||||
|
||||
if self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.group.stop()
|
||||
# Setting limits for animations of the side bar
|
||||
self.width_anim.setStartValue(self.width())
|
||||
self.width_anim.setEndValue(
|
||||
self._expanded_width if self._is_expanded else self._collapsed_width
|
||||
)
|
||||
self.title_anim.setStartValue(self.title_fx.opacity())
|
||||
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
|
||||
|
||||
# Setting limits for animations of the components
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "setup_animations"):
|
||||
comp.setup_animations(self._is_expanded)
|
||||
|
||||
self.group.start()
|
||||
if self._is_expanded:
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
self.toggled.emit(self._is_expanded)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_anim_finished(self):
|
||||
if not self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, theme_name: str):
|
||||
# Refresh toggle arrow icon so it picks up the new theme
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
icon_type=QIcon,
|
||||
)
|
||||
)
|
||||
# Refresh each component that supports it
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "refresh_theme"):
|
||||
comp.refresh_theme()
|
||||
else:
|
||||
comp.style().unpolish(comp)
|
||||
comp.style().polish(comp)
|
||||
comp.update()
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
|
||||
"""
|
||||
Add a section header to the side bar.
|
||||
|
||||
Args:
|
||||
title(str): The title of the section.
|
||||
id(str): Unique ID for the section.
|
||||
position(int, optional): Position to insert the section header.
|
||||
|
||||
Returns:
|
||||
SectionHeader: The created section header.
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = header
|
||||
return header
|
||||
|
||||
def add_separator(
|
||||
self, *, from_top: bool = True, position: int | None = None
|
||||
) -> SideBarSeparator:
|
||||
"""
|
||||
Add a separator line to the side bar. Separators are treated like regular
|
||||
items; you can place multiple separators anywhere using `from_top` and `position`.
|
||||
"""
|
||||
line = SideBarSeparator(self)
|
||||
line.setStyleSheet("margin:12px;")
|
||||
self._insert_nav_item(line, from_top=from_top, position=position)
|
||||
return line
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the nav item.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
"""
|
||||
item = NavigationItem(
|
||||
parent=self,
|
||||
title=title,
|
||||
icon_name=icon,
|
||||
mini_text=mini_text,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
# Connect activation to activation logic, passing id unchanged
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
target = self.components.get(target_id)
|
||||
if target is None:
|
||||
return
|
||||
# Non-toggleable acts like an action: do not change any toggled states
|
||||
if hasattr(target, "toggleable") and not target.toggleable:
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
|
||||
is_exclusive = getattr(target, "exclusive", True)
|
||||
if is_exclusive:
|
||||
# Radio-like behavior among exclusive items only
|
||||
for comp_id, comp in self.components.items():
|
||||
if not isinstance(comp, NavigationItem):
|
||||
continue
|
||||
if comp is target:
|
||||
comp.set_active(True)
|
||||
else:
|
||||
# Only untoggle other items that are also exclusive
|
||||
if getattr(comp, "exclusive", True):
|
||||
comp.set_active(False)
|
||||
# Leave non-exclusive items as they are
|
||||
else:
|
||||
# Non-exclusive toggles independently
|
||||
target.set_active(not target.is_active())
|
||||
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def add_dark_mode_item(
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> DarkModeNavItem:
|
||||
"""
|
||||
Add a dark mode toggle item to the side bar.
|
||||
|
||||
Args:
|
||||
id(str): Unique ID for the dark mode item.
|
||||
position(int, optional): Position to insert the dark mode item.
|
||||
|
||||
Returns:
|
||||
DarkModeNavItem: The created dark mode navigation item.
|
||||
"""
|
||||
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
|
||||
# compute bottom insertion point (same semantics as from_top=False)
|
||||
self._insert_nav_item(item, from_top=False, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def _insert_nav_item(
|
||||
self, item: QWidget, *, from_top: bool = True, position: int | None = None
|
||||
):
|
||||
if from_top:
|
||||
base_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
pos = base_index if position is None else min(base_index, position)
|
||||
else:
|
||||
base = self.content_layout.indexOf(self._bottom_spacer) + 1
|
||||
pos = base if position is None else base + max(0, position)
|
||||
self.content_layout.insertWidget(pos, item)
|
||||
@@ -0,0 +1,372 @@
|
||||
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
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import (
|
||||
ANIMATION_DURATION,
|
||||
RevealAnimator,
|
||||
)
|
||||
|
||||
|
||||
def get_on_primary():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("ON_PRIMARY")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def get_fg():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("FG")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
class SideBarSeparator(QFrame):
|
||||
"""A horizontal line separator for use in SideBar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SideBarSeparator")
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(2)
|
||||
self.setProperty("variant", "separator")
|
||||
|
||||
|
||||
class SectionHeader(QWidget):
|
||||
"""A section header with a label and a horizontal line below."""
|
||||
|
||||
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SectionHeader")
|
||||
|
||||
self.lbl = QLabel(text, self)
|
||||
self.lbl.setObjectName("SectionHeaderLabel")
|
||||
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
|
||||
|
||||
self.line = SideBarSeparator(self)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
# keep your margins/spacing preferences here if needed
|
||||
lay.setContentsMargins(12, 0, 12, 0)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.lbl)
|
||||
lay.addWidget(self.line)
|
||||
|
||||
self.animations = self.build_animations()
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return self._reveal.animations()
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self._reveal.setup(expanded)
|
||||
|
||||
|
||||
class NavigationItem(QWidget):
|
||||
"""A nav tile with an icon + labels and an optional expandable body.
|
||||
Provides animations for collapsed/expanded sidebar states via
|
||||
build_animations()/setup_animations(), similar to SectionHeader.
|
||||
"""
|
||||
|
||||
activated = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
title: str,
|
||||
icon_name: str,
|
||||
mini_text: str | None = None,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavigationItem")
|
||||
|
||||
# Private attributes
|
||||
self._title = title
|
||||
self._icon_name = icon_name
|
||||
self._mini_text = mini_text or title
|
||||
self._toggleable = toggleable
|
||||
self._toggled = False
|
||||
self._exclusive = exclusive
|
||||
|
||||
# Main Icon
|
||||
self.icon_btn = QToolButton(self)
|
||||
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, icon_type=QIcon))
|
||||
self.icon_btn.setAutoRaise(True)
|
||||
self._icon_size_collapsed = QtCore.QSize(20, 20)
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
self.mini_lbl.setObjectName("NavMiniLabel")
|
||||
self.mini_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.mini_lbl.setStyleSheet("font-size: 10px;")
|
||||
self.reveal_mini_lbl = RevealAnimator(
|
||||
widget=self.mini_lbl,
|
||||
initially_revealed=True,
|
||||
animate_width=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
|
||||
# Container for icon + mini label
|
||||
self.mini_icon = QWidget(self)
|
||||
mini_lay = QVBoxLayout(self.mini_icon)
|
||||
mini_lay.setContentsMargins(0, 2, 0, 2)
|
||||
mini_lay.setSpacing(2)
|
||||
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
|
||||
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
|
||||
|
||||
# Title label
|
||||
self.title_lbl = QLabel(self._title, self)
|
||||
self.title_lbl.setObjectName("NavTitleLabel")
|
||||
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.title_lbl.setStyleSheet("font-size: 13px;")
|
||||
self.reveal_title_lbl = RevealAnimator(
|
||||
widget=self.title_lbl,
|
||||
initially_revealed=False,
|
||||
animate_height=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(12, 2, 12, 2)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
|
||||
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
|
||||
self.icon_size_anim.setDuration(anim_duration)
|
||||
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
# Connect icon button to emit activation
|
||||
self.icon_btn.clicked.connect(self._emit_activated)
|
||||
self.setMouseTracking(True)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the item is currently active/selected."""
|
||||
return self.property("toggled") is True
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return (
|
||||
self.reveal_title_lbl.animations()
|
||||
+ self.reveal_mini_lbl.animations()
|
||||
+ [self.icon_size_anim]
|
||||
)
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self.reveal_mini_lbl.setup(not expanded)
|
||||
self.reveal_title_lbl.setup(expanded)
|
||||
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
|
||||
self.icon_size_anim.setEndValue(
|
||||
self._icon_size_expanded if expanded else self._icon_size_collapsed
|
||||
)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the title label."""
|
||||
self.title_lbl.setVisible(visible)
|
||||
|
||||
def _emit_activated(self):
|
||||
self.activated.emit()
|
||||
|
||||
def set_active(self, active: bool):
|
||||
"""
|
||||
Set the active/selected state of the item.
|
||||
|
||||
Args:
|
||||
active(bool): True to set active, False to deactivate.
|
||||
"""
|
||||
self.setProperty("toggled", active)
|
||||
self.toggled = active
|
||||
# ensure style refresh
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.activated.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggleable(self) -> bool:
|
||||
"""
|
||||
Whether the item is toggleable (like a button) or not (like an action).
|
||||
|
||||
Returns:
|
||||
bool: True if toggleable, False otherwise.
|
||||
"""
|
||||
return self._toggleable
|
||||
|
||||
@toggleable.setter
|
||||
def toggleable(self, value: bool):
|
||||
"""
|
||||
Set whether the item is toggleable (like a button) or not (like an action).
|
||||
Args:
|
||||
value(bool): True to make toggleable, False otherwise.
|
||||
"""
|
||||
self._toggleable = bool(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggled(self) -> bool:
|
||||
"""
|
||||
Whether the item is currently toggled/selected.
|
||||
|
||||
Returns:
|
||||
bool: True if toggled, False otherwise.
|
||||
"""
|
||||
return self._toggled
|
||||
|
||||
@toggled.setter
|
||||
def toggled(self, value: bool):
|
||||
"""
|
||||
Set whether the item is currently toggled/selected.
|
||||
|
||||
Args:
|
||||
value(bool): True to set toggled, False to untoggle.
|
||||
"""
|
||||
self._toggled = value
|
||||
if value:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=True, color=get_on_primary(), icon_type=QIcon
|
||||
)
|
||||
else:
|
||||
new_icon = material_icon(self._icon_name, filled=False, color=get_fg(), icon_type=QIcon)
|
||||
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):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
Whether the item is exclusive in its toggle group.
|
||||
|
||||
Returns:
|
||||
bool: True if exclusive, False otherwise.
|
||||
"""
|
||||
return self._exclusive
|
||||
|
||||
@exclusive.setter
|
||||
def exclusive(self, value: bool):
|
||||
"""
|
||||
Set whether the item is exclusive in its toggle group.
|
||||
|
||||
Args:
|
||||
value(bool): True to make exclusive, False otherwise.
|
||||
"""
|
||||
self._exclusive = bool(value)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Recompute icon/label colors according to current theme and state
|
||||
# Trigger the toggled setter to rebuild the icon with the correct color
|
||||
self.toggled = self._toggled
|
||||
# Ensure QSS-driven text/icon colors refresh
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
|
||||
class DarkModeNavItem(NavigationItem):
|
||||
"""Bottom action item that toggles app theme and updates its icon/text."""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
title="Dark mode",
|
||||
icon_name="dark_mode",
|
||||
mini_text="Dark",
|
||||
toggleable=False, # action-like, no selection highlight changes
|
||||
exclusive=False,
|
||||
anim_duration=anim_duration,
|
||||
)
|
||||
self._id = id
|
||||
self._sync_from_qapp_theme()
|
||||
self.activated.connect(self.toggle_theme)
|
||||
|
||||
def _qapp_dark_enabled(self) -> bool:
|
||||
qapp = QApplication.instance()
|
||||
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
|
||||
|
||||
def _sync_from_qapp_theme(self):
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
# Update labels
|
||||
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
|
||||
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)
|
||||
)
|
||||
|
||||
def refresh_theme(self):
|
||||
self._sync_from_qapp_theme()
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Toggle application theme and update icon/text."""
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
|
||||
apply_theme("light" if is_dark else "dark")
|
||||
self._sync_from_qapp_theme()
|
||||
0
bec_widgets/applications/views/__init__.py
Normal file
0
bec_widgets/applications/views/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
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_())
|
||||
@@ -0,0 +1,347 @@
|
||||
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_())
|
||||
@@ -0,0 +1,687 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
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 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
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTableView,
|
||||
DMConfigView,
|
||||
DMOphydTest,
|
||||
DocstringView,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
|
||||
AvailableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
PresetClassDeviceConfigDialog,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
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 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):
|
||||
super().__init__(parent=parent, client=None, *args, **kwargs)
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# 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 = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTableView(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
|
||||
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
|
||||
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
|
||||
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_test_view = DMOphydTest(self)
|
||||
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,
|
||||
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)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_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,
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
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])
|
||||
|
||||
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.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,),
|
||||
),
|
||||
]:
|
||||
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):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _add_io_actions(self):
|
||||
# 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",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
save_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
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")
|
||||
|
||||
# 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",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# 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")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
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)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
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)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# 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",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
# IO actions
|
||||
def _coming_soon(self):
|
||||
return QMessageBox.question(
|
||||
self,
|
||||
"Not implemented yet",
|
||||
"This feature has not been implemented yet, will be coming soon...!!",
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
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)
|
||||
|
||||
# TODO would we ever like to add the current config to an existing composition
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis"""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Push composition to Redis",
|
||||
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if self.device_table_view.table.contains_invalid_devices():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has errors!", "Please resolve before proceeding."
|
||||
)
|
||||
if self.ophyd_test_view.validation_running():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has not completed.", "Please wait for the validation to finish."
|
||||
)
|
||||
self._push_composition_to_redis()
|
||||
|
||||
def _push_composition_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")
|
||||
threadpool.start(comm)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
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")
|
||||
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."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
# TODO Bespoke Form to add a new device
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = PresetClassDeviceConfigDialog(parent=self)
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _add_to_table_from_dialog(self, data):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
self.device_table_view.remove_selected_rows()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _rerun_validation_action(self, connect: bool = True):
|
||||
"""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)
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(
|
||||
self, horizontal_weights: list, vertical_weights: list
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""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
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerView()
|
||||
l.addWidget(device_manager_view)
|
||||
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
# cfg = yaml_load(config_path)
|
||||
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
|
||||
# # config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||
# device_manager_view.device_table_view.set_device_config(cfg)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
w.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Top Level wrapper for device_manager widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
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
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Add device manager view
|
||||
self.device_manager_view = DeviceManagerView()
|
||||
self.stacked_layout.addWidget(self.device_manager_view)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||
)
|
||||
# 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)
|
||||
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)
|
||||
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)
|
||||
self._overlay_widget.setVisible(True)
|
||||
|
||||
def _load_config_from_file_clicked(self):
|
||||
"""Handle click on 'Load Config From File' button."""
|
||||
start_dir = os.path.expanduser("~")
|
||||
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
try:
|
||||
config = yaml_load(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
config_list = []
|
||||
for name, cfg in config.items():
|
||||
config_list.append(cfg)
|
||||
config_list[-1]["name"] = name
|
||||
self.device_manager_view.device_table_view.set_device_config(config_list)
|
||||
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
self.device_manager_view.device_table_view.set_device_config(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager = DeviceManagerWidget()
|
||||
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||
# device_manager.device_table_view.set_device_config(config)
|
||||
layout.addWidget(device_manager)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
dark_mode_button = DarkModeButton()
|
||||
layout.addWidget(dark_mode_button)
|
||||
widget.show()
|
||||
device_manager.setWindowTitle("Device Manager View")
|
||||
device_manager.resize(1600, 1200)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
363
bec_widgets/applications/views/view.py
Normal file
363
bec_widgets/applications/views/view.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
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.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
|
||||
if content is not None:
|
||||
self.set_content(content)
|
||||
|
||||
def set_content(self, content: QWidget) -> None:
|
||||
"""Replace the current content widget with a new one."""
|
||||
if self.content is not None:
|
||||
self.content.setParent(None)
|
||||
self.content = content
|
||||
self.layout().addWidget(content)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
"""Called before the view is switched away/hidden.
|
||||
|
||||
Return True to allow switching, or False to veto.
|
||||
Default implementation allows switching.
|
||||
"""
|
||||
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
|
||||
####################################################################################################
|
||||
|
||||
|
||||
# --- Popup UI version ---
|
||||
class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
self.waveform = Waveform(parent=self)
|
||||
self.set_content(self.waveform)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Configure Waveform View")
|
||||
|
||||
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
|
||||
|
||||
# same as in the CurveRow used in waveform
|
||||
self.device_edit = DeviceComboBox(parent=self)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
|
||||
v = QVBoxLayout(dialog)
|
||||
v.addLayout(form)
|
||||
v.addWidget(buttons)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Switch and clear?",
|
||||
"Do you want to switch views and clear the plot?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Inline stacked UI version ---
|
||||
class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Root layout for this view uses a stacked layout
|
||||
self.stack = QStackedLayout()
|
||||
container = QWidget(self)
|
||||
container.setLayout(self.stack)
|
||||
self.set_content(container)
|
||||
|
||||
# --- Page 0: Settings page (inline form)
|
||||
self.settings_page = QWidget()
|
||||
sp_layout = QVBoxLayout(self.settings_page)
|
||||
sp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
sp_layout.setSpacing(12)
|
||||
|
||||
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
|
||||
self.device_edit = DeviceComboBox(parent=self.settings_page)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
self.entry_edit = SignalComboBox(parent=self.settings_page)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(title)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK", parent=self.settings_page)
|
||||
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addWidget(ok_btn)
|
||||
|
||||
sp_layout.addLayout(form)
|
||||
sp_layout.addLayout(btn_row)
|
||||
|
||||
# --- Page 1: Waveform page
|
||||
self.waveform_page = QWidget()
|
||||
wf_layout = QVBoxLayout(self.waveform_page)
|
||||
wf_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.waveform = Waveform(parent=self.waveform_page)
|
||||
wf_layout.addWidget(self.waveform)
|
||||
|
||||
# --- Page 2: Exit confirmation page (inline)
|
||||
self.confirm_page = QWidget()
|
||||
cp_layout = QVBoxLayout(self.confirm_page)
|
||||
cp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
cp_layout.setSpacing(12)
|
||||
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
|
||||
cp_buttons = QHBoxLayout()
|
||||
no_btn = QPushButton("No", parent=self.confirm_page)
|
||||
yes_btn = QPushButton("Yes", parent=self.confirm_page)
|
||||
cp_buttons.addStretch(1)
|
||||
cp_buttons.addWidget(no_btn)
|
||||
cp_buttons.addWidget(yes_btn)
|
||||
cp_layout.addWidget(qlabel)
|
||||
cp_layout.addLayout(cp_buttons)
|
||||
|
||||
# Add pages to the stack
|
||||
self.stack.addWidget(self.settings_page) # index 0
|
||||
self.stack.addWidget(self.waveform_page) # index 1
|
||||
self.stack.addWidget(self.confirm_page) # index 2
|
||||
|
||||
# Wire settings buttons
|
||||
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
|
||||
cancel_btn.clicked.connect(self._show_waveform_without_changes)
|
||||
|
||||
# Prepare result holder for the inline confirmation
|
||||
self._exit_choice_yes = None
|
||||
yes_btn.clicked.connect(lambda: self._exit_reply(True))
|
||||
no_btn.clicked.connect(lambda: self._exit_reply(False))
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
# Always start on the settings page when entering
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
# Show inline confirmation page and synchronously wait for a choice
|
||||
# -> trick to make the choice blocking, however popup would be cleaner solution
|
||||
self._exit_choice_yes = None
|
||||
self.stack.setCurrentIndex(2)
|
||||
loop = QEventLoop()
|
||||
self._exit_loop = loop
|
||||
loop.exec_()
|
||||
|
||||
if self._exit_choice_yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
# Revert to waveform view if user cancelled switching
|
||||
self.stack.setCurrentIndex(1)
|
||||
return False
|
||||
|
||||
def _apply_settings_and_show_waveform(self):
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
# Just show waveform page without plotting
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _exit_reply(self, yes: bool):
|
||||
self._exit_choice_yes = bool(yes)
|
||||
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
|
||||
self._exit_loop.quit()
|
||||
@@ -27,7 +27,6 @@ class _WidgetsEnumType(str, enum.Enum):
|
||||
|
||||
|
||||
_Widgets = {
|
||||
"AbortButton": "AbortButton",
|
||||
"BECDockArea": "BECDockArea",
|
||||
"BECMainWindow": "BECMainWindow",
|
||||
"BECProgressBar": "BECProgressBar",
|
||||
@@ -50,7 +49,6 @@ _Widgets = {
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
"PositionerControlLine": "PositionerControlLine",
|
||||
"PositionerGroup": "PositionerGroup",
|
||||
"ResetButton": "ResetButton",
|
||||
"ResumeButton": "ResumeButton",
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"SBBMonitor": "SBBMonitor",
|
||||
@@ -60,7 +58,6 @@ _Widgets = {
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"StopButton": "StopButton",
|
||||
"TextBox": "TextBox",
|
||||
"VSCodeEditor": "VSCodeEditor",
|
||||
"Waveform": "Waveform",
|
||||
@@ -97,13 +94,86 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AbortButton(RPCBase):
|
||||
"""A button that abort the scan."""
|
||||
class AdvancedDockArea(RPCBase):
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
widget: "BECWidget | str",
|
||||
closable: "bool" = True,
|
||||
floatable: "bool" = True,
|
||||
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.
|
||||
|
||||
Args:
|
||||
widget: Widget instance or a string widget type (factory-created).
|
||||
closable: Whether the dock is closable.
|
||||
floatable: Whether the dock is floatable.
|
||||
movable: Whether the dock is movable.
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
def widget_map(self) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to BECWidget instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of all BECWidget instances in the dock area.
|
||||
|
||||
Returns:
|
||||
list: A list of all BECWidget instances in the dock area.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_workspace(self) -> "bool":
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
Returns:
|
||||
bool: True if the workspace is locked, False otherwise.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_all(self):
|
||||
"""
|
||||
Delete all docks and widgets.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@mode.setter
|
||||
@rpc_call
|
||||
def mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
|
||||
@@ -143,6 +213,26 @@ class AutoUpdates(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class AvailableDeviceResources(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class BECDock(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
@@ -442,6 +532,18 @@ class BECMainWindow(RPCBase):
|
||||
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 BECProgressBar(RPCBase):
|
||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||
@@ -525,6 +627,18 @@ class BECQueue(RPCBase):
|
||||
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 BECStatusBox(RPCBase):
|
||||
"""An autonomous widget to display the status of BEC services."""
|
||||
@@ -541,6 +655,25 @@ class BECStatusBox(RPCBase):
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class BaseROI(RPCBase):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
@@ -964,6 +1097,48 @@ class Curve(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DMConfigView(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DMOphydTest(RPCBase):
|
||||
"""Widget to test device configurations using ophyd devices."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
|
||||
@@ -1002,6 +1177,18 @@ class DarkModeButton(RPCBase):
|
||||
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 DeviceBrowser(RPCBase):
|
||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||
@@ -1012,6 +1199,18 @@ class DeviceBrowser(RPCBase):
|
||||
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 DeviceComboBox(RPCBase):
|
||||
"""Combobox widget for device input with autocomplete for device names."""
|
||||
@@ -1045,6 +1244,18 @@ class DeviceInputBase(RPCBase):
|
||||
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 DeviceLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
@@ -1433,6 +1644,18 @@ class Heatmap(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -1978,6 +2201,18 @@ class Image(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -2451,26 +2686,54 @@ 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) -> None:
|
||||
def set_text(
|
||||
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
||||
) -> "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.
|
||||
|
||||
@@ -2481,7 +2744,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.
|
||||
|
||||
@@ -2490,7 +2753,16 @@ class MonacoWidget(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_language(self, language: str) -> None:
|
||||
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":
|
||||
"""
|
||||
Set the programming language for syntax highlighting in the Monaco editor.
|
||||
|
||||
@@ -2499,13 +2771,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.
|
||||
|
||||
@@ -2514,13 +2786,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.
|
||||
|
||||
@@ -2531,10 +2803,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.
|
||||
|
||||
@@ -2545,7 +2817,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.
|
||||
|
||||
@@ -2554,7 +2826,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.
|
||||
|
||||
@@ -2563,7 +2835,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.
|
||||
|
||||
@@ -2572,7 +2844,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.
|
||||
@@ -2582,7 +2854,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.
|
||||
|
||||
@@ -2590,6 +2862,25 @@ class MonacoWidget(RPCBase):
|
||||
str: The LSP header.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
@@ -2865,6 +3156,18 @@ class MotorMap(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3277,6 +3580,18 @@ class MultiWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3498,6 +3813,18 @@ class PositionerBox(RPCBase):
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3527,6 +3854,18 @@ class PositionerBox2D(RPCBase):
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3547,6 +3886,18 @@ class PositionerControlLine(RPCBase):
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3566,6 +3917,25 @@ class PositionerGroup(RPCBase):
|
||||
Device names must be separated by space
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
@@ -3696,16 +4066,6 @@ class RectangularROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ResetButton(RPCBase):
|
||||
"""A button that resets the scan queue."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
|
||||
class ResumeButton(RPCBase):
|
||||
"""A button that continue scan queue."""
|
||||
|
||||
@@ -3715,6 +4075,18 @@ class ResumeButton(RPCBase):
|
||||
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 Ring(RPCBase):
|
||||
@rpc_call
|
||||
@@ -3996,6 +4368,25 @@ class RingProgressBar(RPCBase):
|
||||
bool: True if scan segment updates are enabled.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class SBBMonitor(RPCBase):
|
||||
"""A widget to display the SBB monitor website."""
|
||||
@@ -4007,9 +4398,15 @@ class ScanControl(RPCBase):
|
||||
"""Widget to submit new scans to the queue."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
def attach(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@@ -4029,6 +4426,18 @@ class ScanProgressBar(RPCBase):
|
||||
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 ScatterCurve(RPCBase):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
@@ -4327,6 +4736,18 @@ class ScatterWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -4620,16 +5041,6 @@ class SignalLineEdit(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class StopButton(RPCBase):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
|
||||
class TextBox(RPCBase):
|
||||
"""A widget that displays text in plain and HTML format"""
|
||||
|
||||
@@ -4661,6 +5072,25 @@ class VSCodeEditor(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
@@ -4965,13 +5395,6 @@ class Waveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -5219,6 +5642,18 @@ class WebConsole(RPCBase):
|
||||
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 WebsiteWidget(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
@@ -5258,3 +5693,22 @@ class WebsiteWidget(RPCBase):
|
||||
"""
|
||||
Go forward in the history
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@@ -7,8 +7,10 @@ import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
@@ -92,6 +94,11 @@ class GUIServer:
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
else:
|
||||
apply_theme("light")
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
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,
|
||||
@@ -14,147 +25,359 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
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 (contains Qt and pyqtgraph API)."""
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
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, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self._widgets_by_name: Dict[str, QWidget] = {}
|
||||
self._init_ui()
|
||||
|
||||
# console push
|
||||
# expose helper API and basics in the inprocess console
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
# "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,
|
||||
}
|
||||
)
|
||||
# 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})
|
||||
|
||||
def _init_ui(self):
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
# Horizontal splitter
|
||||
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
|
||||
splitter = QSplitter(self)
|
||||
self.layout.addWidget(splitter)
|
||||
|
||||
tab_widget = QTabWidget(splitter)
|
||||
# Left: tabs that will host dynamically added widgets
|
||||
self.tab_widget = QTabWidget(splitter)
|
||||
|
||||
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")
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
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")
|
||||
#
|
||||
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.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
#
|
||||
# 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")
|
||||
|
||||
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")
|
||||
# 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 closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.dock.close()
|
||||
# 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.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
@@ -168,18 +391,26 @@ if __name__ == "__main__": # pragma: no cover
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
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_())
|
||||
|
||||
@@ -77,6 +77,8 @@ class BECConnector:
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -127,6 +129,17 @@ 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()
|
||||
|
||||
@@ -204,6 +217,10 @@ class BECConnector:
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
@@ -450,6 +467,7 @@ class BECConnector:
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtGui import QIcon, QPixmap
|
||||
|
||||
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, convert_to_pixmap=True))
|
||||
return QIcon(material_icon(icon_name, filled=True, icon_type=QPixmap))
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
import PySide6QtAds as QtAds
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
@@ -11,9 +11,9 @@ from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -27,7 +27,7 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
USER_ACCESS = ["remove", "attach", "detach"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -36,6 +36,8 @@ class BECWidget(BECConnector):
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
**kwargs,
|
||||
):
|
||||
@@ -45,8 +47,7 @@ class BECWidget(BECConnector):
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
>>> QWidget.__init__(self, parent=parent)
|
||||
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
|
||||
Args:
|
||||
@@ -62,25 +63,32 @@ class BECWidget(BECConnector):
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
# Instead, we will set the theme to the system setting on startup
|
||||
if darkdetect.isDark():
|
||||
set_theme("dark")
|
||||
else:
|
||||
set_theme("light")
|
||||
|
||||
if theme_update:
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._loading = False
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
try:
|
||||
overlay = self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if overlay is not None:
|
||||
overlay.setGeometry(self.rect())
|
||||
overlay.raise_()
|
||||
overlay.show()
|
||||
self._loading = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Busy loader init skipped: {exc}")
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
if hasattr(qapp, "theme"):
|
||||
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -89,8 +97,77 @@ class BECWidget(BECConnector):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
Returns the overlay instance or None if not a QWidget.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return None
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
return overlay
|
||||
|
||||
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the loading overlay.
|
||||
text(str, optional): The text to display on the overlay. If None, the text is not changed.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if getattr(self, "_busy_overlay", None) is None:
|
||||
self._ensure_busy_overlay(busy_text=text or "Loading…")
|
||||
if text is not None:
|
||||
self.set_busy_text(text)
|
||||
if enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
|
||||
def is_busy(self) -> bool:
|
||||
"""
|
||||
Check if the loading overlay is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if the loading overlay is enabled, False otherwise.
|
||||
"""
|
||||
return bool(getattr(self, "_loading", False))
|
||||
|
||||
def set_busy_text(self, text: str) -> None:
|
||||
"""
|
||||
Update the text on the loading overlay.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
overlay = self._ensure_busy_overlay(busy_text=text)
|
||||
if overlay is not None:
|
||||
overlay.set_text(text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
@@ -100,6 +177,23 @@ class BECWidget(BECConnector):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
def get_help_md(self) -> str:
|
||||
"""
|
||||
Method to override in subclasses to provide help text in markdown format.
|
||||
|
||||
Returns:
|
||||
str: The help text in markdown format.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
@@ -124,6 +218,26 @@ class BECWidget(BECConnector):
|
||||
screenshot.save(file_name)
|
||||
logger.info(f"Screenshot saved to {file_name}")
|
||||
|
||||
def attach(self):
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
|
||||
if not dock.isFloating():
|
||||
return
|
||||
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
if dock.isFloating():
|
||||
return
|
||||
dock.setFloating()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
@@ -138,6 +252,22 @@ class BECWidget(BECConnector):
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
self._busy_overlay = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
|
||||
253
bec_widgets/utils/busy_loader.py
Normal file
253
bec_widgets/utils/busy_loader.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
"""Keeps the overlay sized and stacked over its target widget."""
|
||||
|
||||
def __init__(self, target: QWidget, overlay: QWidget):
|
||||
super().__init__(target)
|
||||
self._target = target
|
||||
self._overlay = overlay
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._target and event.type() in (
|
||||
QEvent.Resize,
|
||||
QEvent.Show,
|
||||
QEvent.LayoutRequest,
|
||||
QEvent.Move,
|
||||
):
|
||||
self._overlay.setGeometry(self._target.rect())
|
||||
self._overlay.raise_()
|
||||
return False
|
||||
|
||||
|
||||
class BusyLoaderOverlay(QWidget):
|
||||
"""
|
||||
A semi-transparent scrim with centered text and an animated spinner.
|
||||
Call show()/hide() directly, or use via `install_busy_loader(...)`.
|
||||
|
||||
Args:
|
||||
parent(QWidget): The parent widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAutoFillBackground(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self._opacity = opacity
|
||||
|
||||
self._label = QLabel(text, self)
|
||||
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(self._label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
self._label.setFont(f)
|
||||
|
||||
self._spinner = SpinnerWidget(self)
|
||||
self._spinner.setFixedSize(42, 42)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(self._label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
"""
|
||||
Update the overlay text.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
|
||||
"""
|
||||
self._opacity = max(0.0, min(1.0, float(opacity)))
|
||||
# Re-apply alpha using the current theme color
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
|
||||
def update_palette(self):
|
||||
"""
|
||||
Update colors from the current application theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme # type: ignore[attr-defined]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
# Semi-transparent scrim derived from bg
|
||||
self._scrim_color = QColor(self._bg)
|
||||
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self._spinner.update()
|
||||
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
|
||||
self._label.setStyleSheet(f"color: {fg_hex};")
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
) -> BusyLoaderOverlay:
|
||||
"""
|
||||
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
|
||||
|
||||
Args:
|
||||
target(QWidget): The widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
|
||||
)
|
||||
|
||||
self._title = QLabel("Demo Content", self)
|
||||
self._title.setAlignment(Qt.AlignCenter)
|
||||
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(self._title)
|
||||
waveform = Waveform(self)
|
||||
waveform.plot([1, 2, 3, 4, 5])
|
||||
lay.addWidget(waveform, 1)
|
||||
|
||||
QTimer.singleShot(5000, self._ready)
|
||||
|
||||
def _ready(self):
|
||||
self._title.setText("Ready ✓")
|
||||
self.set_busy(False)
|
||||
|
||||
|
||||
class DemoWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget()
|
||||
right = DemoWidget()
|
||||
|
||||
btn_on = QPushButton("Right → Loading")
|
||||
btn_off = QPushButton("Right → Ready")
|
||||
btn_text = QPushButton("Set custom text")
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
|
||||
|
||||
panel = QWidget()
|
||||
prow = QVBoxLayout(panel)
|
||||
prow.addWidget(btn_on)
|
||||
prow.addWidget(btn_off)
|
||||
prow.addWidget(btn_text)
|
||||
prow.addStretch(1)
|
||||
|
||||
central = QWidget()
|
||||
row = QHBoxLayout(central)
|
||||
row.setContentsMargins(12, 12, 12, 12)
|
||||
row.setSpacing(12)
|
||||
row.addWidget(left, 1)
|
||||
row.addWidget(right, 1)
|
||||
row.addWidget(panel, 0)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.resize(900, 420)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,19 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
@@ -23,118 +21,35 @@ def get_theme_name():
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
# FIXME this is legacy code, should be removed in the future
|
||||
app = QApplication.instance()
|
||||
palette = app.palette()
|
||||
return palette
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
def get_accent_colors() -> AccentColors:
|
||||
"""
|
||||
Get the accent colors for the current theme. These colors are extensions of the color palette
|
||||
and are used to highlight specific elements in the UI.
|
||||
"""
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return None
|
||||
accent_colors = AccentColors()
|
||||
return accent_colors
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
def _theme_update_callback():
|
||||
"""
|
||||
Internal callback function to update the theme based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# pylint: disable=protected-access
|
||||
app.theme.theme = app.os_listener._theme.lower()
|
||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
||||
apply_theme(app.os_listener._theme.lower())
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||
|
||||
app.theme_signal.theme_updated.emit(theme)
|
||||
apply_theme(theme)
|
||||
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||
app.installEventFilter(app.os_listener)
|
||||
def process_all_deferred_deletes(qapp):
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
qapp.processEvents(QEventLoop.AllEvents)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Literal
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtGui import QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
@@ -12,6 +11,7 @@ from qtpy.QtWidgets import (
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -40,7 +40,7 @@ class LedLabel(QLabel):
|
||||
self.setState("default")
|
||||
self.setFixedSize(20, 20)
|
||||
|
||||
def setState(self, state: Literal["success", "default", "warning", "emergency"]):
|
||||
def setState(self, state: str):
|
||||
match state:
|
||||
case "success":
|
||||
r, g, b, a = self.palette.success.getRgb()
|
||||
@@ -123,17 +123,16 @@ class CompactPopupWidget(QWidget):
|
||||
self.compact_view_widget = QWidget(self)
|
||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
QHBoxLayout(self.compact_view_widget)
|
||||
self.compact_view_widget.layout().setSpacing(0)
|
||||
self.compact_view_widget.layout().setSpacing(5)
|
||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.compact_view_widget.layout().addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
)
|
||||
self.compact_label = QLabel(self.compact_view_widget)
|
||||
self.compact_status = LedLabel(self.compact_view_widget)
|
||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setFlat(True)
|
||||
self.compact_show_popup = QToolButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||
material_icon(icon_name="expand_content", size=(10, 10), icon_type=QIcon)
|
||||
)
|
||||
self.compact_view_widget.layout().addWidget(self.compact_label)
|
||||
self.compact_view_widget.layout().addWidget(self.compact_status)
|
||||
@@ -172,9 +171,7 @@ 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), convert_to_pixmap=False
|
||||
)
|
||||
material_icon(icon_name="collapse_content", size=(10, 10), icon_type=QIcon)
|
||||
)
|
||||
self.expand.emit(True)
|
||||
else:
|
||||
@@ -182,9 +179,7 @@ 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), convert_to_pixmap=False
|
||||
)
|
||||
material_icon(icon_name="expand_content", size=(10, 10), icon_type=QIcon)
|
||||
)
|
||||
self.compact_view = True
|
||||
self.expand.emit(False)
|
||||
|
||||
@@ -2,7 +2,9 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
return decorator
|
||||
|
||||
|
||||
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
|
||||
"""Internal function used by SafeConnect to handle weak references to slots."""
|
||||
instance = weak_instance()
|
||||
slot_func = weak_slot()
|
||||
|
||||
# Check if the python object has already been garbage collected
|
||||
if instance is None or slot_func is None:
|
||||
return
|
||||
|
||||
# Check if the python object has already been marked for deletion
|
||||
if getattr(instance, "_destroyed", False):
|
||||
return
|
||||
|
||||
# Check if the C++ object is still valid
|
||||
if not shiboken6.isValid(instance):
|
||||
return
|
||||
|
||||
if connect_args:
|
||||
slot_func(*connect_args)
|
||||
slot_func()
|
||||
|
||||
|
||||
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to safely handle Qt signal-slot connections. The python object is only forwarded
|
||||
as a weak reference to avoid stale objects.
|
||||
|
||||
Args:
|
||||
instance: The instance to connect.
|
||||
signal: The signal to connect to.
|
||||
slot: The slot to connect.
|
||||
|
||||
Example:
|
||||
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
"""
|
||||
weak_instance = safe_ref(instance)
|
||||
weak_slot = safe_ref(slot)
|
||||
|
||||
# Create a partial function that will check weak references before calling the actual slot
|
||||
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
|
||||
|
||||
# Connect the signal to the safe connect slot wrapper
|
||||
return signal.connect(safe_slot)
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -19,7 +20,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
@@ -31,10 +33,11 @@ class ExpandableGroupFrame(QFrame):
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self._title_text = f"<b>{title}</b>"
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setContentsMargins(5, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
@@ -49,21 +52,27 @@ class ExpandableGroupFrame(QFrame):
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._internal_title_layout = QHBoxLayout()
|
||||
self._title_layout.addLayout(self._internal_title_layout)
|
||||
|
||||
self._title = ClickableLabel(f"<b>{title}</b>")
|
||||
self._title = ClickableLabel()
|
||||
self._set_title_text(self._title_text)
|
||||
self._title_icon = ClickableLabel()
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self._internal_title_layout.addWidget(self._title_icon)
|
||||
self._internal_title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
self._internal_title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
def get_title_layout(self) -> QHBoxLayout:
|
||||
return self._internal_title_layout
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
@@ -87,11 +96,9 @@ class ExpandableGroupFrame(QFrame):
|
||||
|
||||
def _update_expansion_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), icon_type=QIcon)
|
||||
if self.expanded
|
||||
else material_icon(
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
else material_icon(icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), icon_type=QIcon)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
@@ -107,11 +114,23 @@ class ExpandableGroupFrame(QFrame):
|
||||
if icon_name:
|
||||
self._title_icon.setVisible(True)
|
||||
self._title_icon.setPixmap(
|
||||
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
|
||||
material_icon(icon_name=icon_name, size=(20, 20), icon_type=QPixmap)
|
||||
)
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
@SafeProperty(str)
|
||||
def title_text(self): # type: ignore
|
||||
return self._title_text
|
||||
|
||||
@title_text.setter
|
||||
def title_text(self, title_text: str):
|
||||
self._title_text = title_text
|
||||
self._set_title_text(self._title_text)
|
||||
|
||||
def _set_title_text(self, title_text: str):
|
||||
self._title.setText(title_text)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import NamedTuple
|
||||
|
||||
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
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
@@ -207,7 +208,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), convert_to_pixmap=False)
|
||||
material_icon(icon_name="info", size=(10, 10), icon_type=QIcon)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
@@ -216,6 +217,9 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self): ...
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
@@ -280,3 +284,24 @@ class PydanticModelForm(TypedForm):
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
|
||||
|
||||
class PydanticModelFormItem(DynamicFormItem):
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
|
||||
) -> None:
|
||||
self._data_model = model
|
||||
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.form_data_updated.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
|
||||
self._main_widget = PydanticModelForm(data_model=self._data_model)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.get_form_data()
|
||||
|
||||
def setValue(self, value: dict):
|
||||
self._main_widget.set_data(self._data_model.model_validate(value))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
@@ -12,8 +13,10 @@ from typing import (
|
||||
Literal,
|
||||
NamedTuple,
|
||||
OrderedDict,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
get_args,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -23,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
|
||||
from qtpy.QtGui import QFontMetrics, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
@@ -168,9 +171,10 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
# Sadly, QWidget and ABC are not compatible
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
@@ -185,6 +189,7 @@ class DynamicFormItem(QWidget):
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget: QWidget
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@@ -198,9 +203,7 @@ class DynamicFormItem(QWidget):
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._clear_button.setIcon(material_icon(icon_name="close", size=(10, 10), icon_type=QIcon))
|
||||
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.")
|
||||
@@ -392,7 +395,7 @@ class ListFormItem(DynamicFormItem):
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
@@ -442,10 +445,17 @@ class ListFormItem(DynamicFormItem):
|
||||
self._add_list_item(val)
|
||||
self._repop(self._data)
|
||||
|
||||
def _item_height(self):
|
||||
return int(QFontMetrics(self.font()).height() * 1.5)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item = QListWidgetItem(self._main_widget)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
||||
item_widget = self._types.widget(parent=self)
|
||||
item_widget.setMinimumHeight(self._item_height())
|
||||
self._main_widget.setGridSize(QSize(0, self._item_height()))
|
||||
if (layout := item_widget.layout()) is not None:
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
WidgetIO.set_value(item_widget, val)
|
||||
self._main_widget.setItemWidget(item, item_widget)
|
||||
self._main_widget.addItem(item)
|
||||
@@ -482,14 +492,11 @@ class ListFormItem(DynamicFormItem):
|
||||
self._data = list(value)
|
||||
self._repop(self._data)
|
||||
|
||||
def _line_height(self):
|
||||
return QFontMetrics(self._main_widget.font()).height()
|
||||
|
||||
def set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
|
||||
def scale_to_data(self, *_):
|
||||
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
||||
@@ -557,7 +564,14 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
@runtime_checkable
|
||||
class _ItemTypeFn(Protocol):
|
||||
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[
|
||||
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
|
||||
]
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
@@ -598,7 +612,10 @@ def widget_from_type(
|
||||
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
||||
for predicate, widget_type in widget_types.values():
|
||||
if predicate(spec):
|
||||
return widget_type
|
||||
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
|
||||
return widget_type
|
||||
return widget_type(spec)
|
||||
|
||||
logger.warning(
|
||||
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
||||
)
|
||||
|
||||
735
bec_widgets/utils/guided_tour.py
Normal file
735
bec_widgets/utils/guided_tour.py
Normal file
@@ -0,0 +1,735 @@
|
||||
"""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())
|
||||
0
bec_widgets/utils/help_inspector/__init__.py
Normal file
0
bec_widgets/utils/help_inspector/__init__.py
Normal file
247
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
247
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Module providing a simple help inspector tool for QtWidgets."""
|
||||
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
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
|
||||
|
||||
|
||||
class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
A help inspector widget that allows to inspect other widgets in the application.
|
||||
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
|
||||
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
|
||||
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
|
||||
The inspector also allows to register custom callback that are called with the inspected widget
|
||||
as argument. This may be useful in the future to hook up more callbacks with custom signals.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): The parent widget of the help inspector.
|
||||
client: Optional client for BECWidget functionality.
|
||||
size (tuple[int, int]): Optional size of the icon for the help inspector.
|
||||
"""
|
||||
|
||||
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
|
||||
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
|
||||
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
layout = QtWidgets.QHBoxLayout(self) # type: ignore
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._active = False
|
||||
self._init_ui()
|
||||
self._callbacks = {}
|
||||
# Register the default callbacks
|
||||
self._register_default_callbacks()
|
||||
# Connect the button toggle signal
|
||||
self._button.toggled.connect(self._toggle_mode)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Init the UI components."""
|
||||
colors: AccentColors = get_accent_colors()
|
||||
self._button = QtWidgets.QToolButton(self.parent())
|
||||
self._button.setCheckable(True)
|
||||
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
self._button.setText("Help Inspect Tool")
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setToolTip("Click to enter Help Mode")
|
||||
self.layout().addWidget(self._button)
|
||||
|
||||
def apply_theme(self, theme: str) -> None:
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
if self._active:
|
||||
self._button.setIcon(self._icon_checked())
|
||||
else:
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _toggle_mode(self, enabled: bool):
|
||||
"""
|
||||
Toggle the help inspection mode.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable or disable the help inspection mode.
|
||||
"""
|
||||
if self._app is None:
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
self._active = enabled
|
||||
if enabled:
|
||||
self._app.installEventFilter(self)
|
||||
self._button.setIcon(self._icon_checked())
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
|
||||
else:
|
||||
self._app.removeEventFilter(self)
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setChecked(False)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
|
||||
"""
|
||||
Filter events to capture Key_Escape event, and mouse clicks
|
||||
if event filter is active. Any click event on a widget is suppressed, if
|
||||
the Inspector is active, and the registered callbacks are called with
|
||||
the clicked widget as argument.
|
||||
|
||||
Args:
|
||||
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:
|
||||
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 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:
|
||||
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}")
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
|
||||
"""
|
||||
Register a callback to be called when a widget is inspected.
|
||||
The callback should be callable with the following signature:
|
||||
callback(widget: QWidget) -> None
|
||||
|
||||
Args:
|
||||
callback (Callable[[QWidget], None]): The callback function to register.
|
||||
Returns:
|
||||
str: A unique ID for the registered callback.
|
||||
"""
|
||||
cb_id = str(uuid4())
|
||||
self._callbacks[cb_id] = callback
|
||||
return cb_id
|
||||
|
||||
def unregister_callback(self, cb_id: str):
|
||||
"""Unregister a previously registered callback."""
|
||||
self._callbacks.pop(cb_id, None)
|
||||
|
||||
def _register_default_callbacks(self):
|
||||
"""Default behavior: publish tooltip, docstring, bec_help"""
|
||||
|
||||
def cb_doc(widget: QtWidgets.QWidget):
|
||||
docstring = widget.__doc__ or "No documentation available."
|
||||
self.widget_docstring.emit(docstring)
|
||||
|
||||
def cb_help(widget: QtWidgets.QWidget):
|
||||
tooltip = widget.toolTip() or "No tooltip available."
|
||||
self.widget_tooltip.emit(tooltip)
|
||||
|
||||
def cb_bec_help(widget: QtWidgets.QWidget):
|
||||
help_text = None
|
||||
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
|
||||
try:
|
||||
help_text = widget.get_help_md()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error retrieving help text from {widget}: {e}")
|
||||
if help_text is None:
|
||||
help_text = widget.toolTip() or "No help available."
|
||||
if not isinstance(help_text, str):
|
||||
logger.error(
|
||||
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
|
||||
)
|
||||
help_text = str(help_text)
|
||||
self.bec_widget_help.emit(help_text)
|
||||
|
||||
self.register_callback(cb_doc)
|
||||
self.register_callback(cb_help)
|
||||
self.register_callback(cb_bec_help)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
apply_theme("dark")
|
||||
main_window.setWindowTitle("Help Inspector Test")
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
dark_mode_button = DarkModeButton(parent=main_window)
|
||||
main_layout.addWidget(dark_mode_button)
|
||||
|
||||
help_inspector = HelpInspector()
|
||||
main_layout.addWidget(help_inspector)
|
||||
|
||||
test_button = QtWidgets.QPushButton("Test Button")
|
||||
test_button.setToolTip("This is a test button.")
|
||||
test_line_edit = QtWidgets.QLineEdit()
|
||||
test_line_edit.setToolTip("This is a test line edit.")
|
||||
test_label = QtWidgets.QLabel("Test Label")
|
||||
test_label.setToolTip("")
|
||||
box = PositionerBox()
|
||||
|
||||
layout_1 = QtWidgets.QHBoxLayout()
|
||||
layout_1.addWidget(test_button)
|
||||
layout_1.addWidget(test_line_edit)
|
||||
layout_1.addWidget(test_label)
|
||||
layout_1.addWidget(box)
|
||||
main_layout.addLayout(layout_1)
|
||||
|
||||
doc_label = QtWidgets.QLabel("Docstring will appear here.")
|
||||
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
|
||||
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
|
||||
main_layout.addWidget(doc_label)
|
||||
main_layout.addWidget(tool_tip_label)
|
||||
main_layout.addWidget(bec_help_label)
|
||||
|
||||
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
|
||||
help_inspector.widget_docstring.connect(doc_label.setText)
|
||||
help_inspector.bec_widget_help.connect(bec_help_label.setText)
|
||||
|
||||
main_window.setCentralWidget(central_widget)
|
||||
main_window.resize(400, 200)
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
133
bec_widgets/utils/list_of_expandable_frames.py
Normal file
133
bec_widgets/utils/list_of_expandable_frames.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import re
|
||||
from functools import partial
|
||||
from re import Pattern
|
||||
from typing import Generic, Iterable, NamedTuple, TypeVar
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.control.device_manager.components._util import (
|
||||
SORT_KEY_ROLE,
|
||||
SortableQListWidgetItem,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
|
||||
|
||||
|
||||
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
|
||||
def __init__(
|
||||
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
|
||||
self.item_tuple = _Items
|
||||
self._item_class = item_class
|
||||
self._item_dict: dict[str, _Items] = {}
|
||||
|
||||
def __contains__(self, id: str):
|
||||
return id in self._item_dict
|
||||
|
||||
def clear(self) -> None:
|
||||
self._item_dict = {}
|
||||
return super().clear()
|
||||
|
||||
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
|
||||
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
|
||||
|
||||
Args:
|
||||
id (str): the key under which to store the list item in the internal dict
|
||||
|
||||
Returns:
|
||||
The widget created in the addition process
|
||||
"""
|
||||
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.takeItem(self.row(item))
|
||||
del self._item_dict[id]
|
||||
self.sortItems()
|
||||
|
||||
def _updatesize(item: QListWidgetItem, item_widget: _EF):
|
||||
item_widget.adjustSize()
|
||||
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
|
||||
|
||||
item = SortableQListWidgetItem(self)
|
||||
item.setData(SORT_KEY_ROLE, id) # used for sorting
|
||||
|
||||
item_widget = self._item_class(*args, **kwargs)
|
||||
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
|
||||
item_widget.imminent_deletion.connect(partial(_remove_item, item))
|
||||
item_widget.broadcast_size_hint.connect(item.setSizeHint)
|
||||
|
||||
self.addItem(item)
|
||||
self.setItemWidget(item, item_widget)
|
||||
self._item_dict[id] = self.item_tuple(item, item_widget)
|
||||
|
||||
item.setSizeHint(item_widget.sizeHint())
|
||||
return (item, item_widget)
|
||||
|
||||
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
|
||||
items = [self.takeItem(0) for i in range(self.count())]
|
||||
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
|
||||
|
||||
for it in items:
|
||||
self.addItem(it)
|
||||
# reattach its custom widget
|
||||
widget = self.itemWidget(it)
|
||||
if widget:
|
||||
self.setItemWidget(it, widget)
|
||||
|
||||
def item_widget_pairs(self):
|
||||
return self._item_dict.values()
|
||||
|
||||
def widgets(self):
|
||||
return (i.widget for i in self._item_dict.values())
|
||||
|
||||
def get_item_widget(self, id: str):
|
||||
if (item := self._item_dict.get(id)) is None:
|
||||
return None
|
||||
return item
|
||||
|
||||
def set_hidden_pattern(self, pattern: Pattern):
|
||||
self.hide_all()
|
||||
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
|
||||
|
||||
def set_hidden(self, ids: Iterable[str]):
|
||||
self._set_hidden(ids, True)
|
||||
|
||||
def _set_hidden(self, ids: Iterable[str], hidden: bool):
|
||||
for id in ids:
|
||||
if (_item := self._item_dict.get(id)) is not None:
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
else:
|
||||
logger.warning(
|
||||
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
|
||||
)
|
||||
self.sortItems()
|
||||
|
||||
def hide_all(self):
|
||||
self.set_hidden_state_on_all(True)
|
||||
|
||||
def unhide_all(self):
|
||||
self.set_hidden_state_on_all(False)
|
||||
|
||||
def set_hidden_state_on_all(self, hidden: bool):
|
||||
for _item in self._item_dict.values():
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
self.sortItems()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_filter(self, value: str):
|
||||
if value == "":
|
||||
return self.unhide_all()
|
||||
try:
|
||||
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
|
||||
except Exception:
|
||||
self.unhide_all()
|
||||
@@ -1,11 +1,12 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtCore import Property, Qt
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(QFrame):
|
||||
# TODO this should be removed completely in favor of QSS styling, no time now
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Ensure QSS can paint background/border on this widget
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
# Create a layout for the frame
|
||||
if orientation == "vertical":
|
||||
self.layout = QVBoxLayout(self)
|
||||
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
|
||||
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
self.update_style()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if self.content_widget is not None and isinstance(
|
||||
self.content_widget, pg.GraphicsLayoutWidget
|
||||
):
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
else:
|
||||
self.background_color = "#141414" # Dark mode
|
||||
|
||||
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
|
||||
self.update_style()
|
||||
|
||||
@Property(int)
|
||||
@@ -77,34 +69,21 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
if self.background_color:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
background-color: {self.background_color};
|
||||
border-radius: {self._radius}; /* Rounded corners */
|
||||
border-radius: {self._radius}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
"""
|
||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
||||
|
||||
Args:
|
||||
border (str): Border style (e.g., 'none', '1px solid red').
|
||||
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
|
||||
"""
|
||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||
# Apply border style via stylesheet
|
||||
self.content_widget.setStyleSheet(
|
||||
f"""
|
||||
GraphicsLayoutWidget {{
|
||||
border: {border}; /* Explicitly set the border */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
self.content_widget.setStyleSheet("")
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover
|
||||
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
|
||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
||||
|
||||
# Add to layout
|
||||
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
|
||||
layout.addWidget(dark_button)
|
||||
layout.addWidget(rounded_plot1)
|
||||
layout.addWidget(rounded_plot2)
|
||||
layout.addWidget(plot1)
|
||||
layout.addWidget(plot2)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
def change_theme():
|
||||
rounded_plot1.apply_theme("light")
|
||||
rounded_plot2.apply_theme("dark")
|
||||
|
||||
QTimer.singleShot(100, change_theme)
|
||||
# Theme flip demo removed; global theming applies automatically
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Literal
|
||||
@@ -33,6 +34,32 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
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:
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
||||
btn.setText(toolbar_action.label_text)
|
||||
toolbar.addWidget(btn)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
@@ -114,15 +141,39 @@ class SeparatorAction(ToolBarAction):
|
||||
|
||||
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
standard_icon,
|
||||
tooltip=None,
|
||||
checkable=False,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Action with a standard Qt icon for the toolbar.
|
||||
|
||||
Args:
|
||||
standard_icon: The standard icon from QStyle.
|
||||
tooltip(str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable(bool, optional): Whether the action is checkable. Defaults to False.
|
||||
label_text(str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent(QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
toolbar.addAction(self.action)
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
@@ -139,6 +190,8 @@ class MaterialIconAction(ToolBarAction):
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
label_text (str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
@@ -149,19 +202,23 @@ class MaterialIconAction(ToolBarAction):
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon.
|
||||
This enables per-action icon text without breaking the existing API.
|
||||
"""
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
self.icon_name, size=(20, 20), icon_type=QIcon, filled=self.filled, color=self.color
|
||||
)
|
||||
if parent is None:
|
||||
logger.warning(
|
||||
@@ -178,7 +235,10 @@ class MaterialIconAction(ToolBarAction):
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
@@ -443,9 +503,13 @@ 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)
|
||||
button.setObjectName("toolbarMenuButton")
|
||||
button.setAutoRaise(True)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
@@ -476,6 +540,14 @@ 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):
|
||||
@@ -522,3 +594,76 @@ 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,11 +6,11 @@ from collections import defaultdict
|
||||
from typing import DefaultDict, Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
@@ -492,10 +492,33 @@ 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.
|
||||
@@ -507,7 +530,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -465,13 +465,19 @@ class WidgetHierarchy:
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
parent = widget.parent()
|
||||
|
||||
# Retrieve first parent
|
||||
parent = widget.parent() if hasattr(widget, "parent") else None
|
||||
# Walk up, validating each step
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -553,6 +559,64 @@ class WidgetHierarchy:
|
||||
WidgetIO.set_value(child, value)
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
@staticmethod
|
||||
def get_bec_connectors_from_parent(widget) -> list:
|
||||
"""
|
||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||
including the widget itself if it is a BECConnector.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
connectors: list[BECConnector] = []
|
||||
if isinstance(widget, BECConnector):
|
||||
connectors.append(widget)
|
||||
for child in widget.findChildren(BECConnector):
|
||||
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||
connectors.append(child)
|
||||
return connectors
|
||||
|
||||
@staticmethod
|
||||
def find_ancestor(widget, ancestor_class) -> QWidget | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest ancestor matching ancestor_class.
|
||||
ancestor_class may be a class or a class-name string.
|
||||
Returns the matching ancestor, or None if none is found.
|
||||
"""
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
|
||||
# If searching for BECConnector specifically, reuse the dedicated helper
|
||||
try:
|
||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
||||
|
||||
if ancestor_class is BECConnector or (
|
||||
isinstance(ancestor_class, str) and ancestor_class == "BECConnector"
|
||||
):
|
||||
return WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
except Exception:
|
||||
# If import fails, fall back to generic traversal below
|
||||
pass
|
||||
|
||||
# Generic traversal across QObject parent chain
|
||||
parent = getattr(widget, "parent", None)
|
||||
if callable(parent):
|
||||
parent = parent()
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
try:
|
||||
if isinstance(ancestor_class, str):
|
||||
if parent.__class__.__name__ == ancestor_class:
|
||||
return parent
|
||||
else:
|
||||
if isinstance(parent, ancestor_class):
|
||||
return parent
|
||||
except Exception:
|
||||
pass
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
|
||||
# Example usage
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
|
||||
@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -29,43 +31,58 @@ class WidgetStateManager:
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
|
||||
def save_state(self, filename: str = None):
|
||||
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Save the state of the widget to an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to save the state to.
|
||||
settings(QSettings): Optional QSettings object to save the state to.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, save the state to the provided QSettings object
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def load_state(self, filename: str = None):
|
||||
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Load the state of the widget from an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to load the state from.
|
||||
settings(QSettings): Optional QSettings object to load the state from.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, load the state from the provided QSettings object
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _save_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Save the state of the widget to QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to save the state for.
|
||||
settings(QSettings): The QSettings object to save the state to.
|
||||
recursive(bool): Whether to recursively save the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -88,21 +105,32 @@ class WidgetStateManager:
|
||||
settings.endGroup()
|
||||
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
if not recursive:
|
||||
return
|
||||
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._save_widget_state_qsettings(child, settings)
|
||||
self._save_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _load_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Load the state of the widget from QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to load the state for.
|
||||
settings(QSettings): The QSettings object to load the state from.
|
||||
recursive(bool): Whether to recursively load the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -118,14 +146,21 @@ class WidgetStateManager:
|
||||
widget.setProperty(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
if not recursive:
|
||||
return
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._load_widget_state_qsettings(child, settings)
|
||||
self._load_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _get_full_widget_name(self, widget: QWidget):
|
||||
"""
|
||||
|
||||
1
bec_widgets/widgets/containers/ads/__init__.py
Normal file
1
bec_widgets/widgets/containers/ads/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from PySide6QtAds import *
|
||||
1039
bec_widgets/widgets/containers/ads/__init__.pyi
Normal file
1039
bec_widgets/widgets/containers/ads/__init__.pyi
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,935 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal, cast
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from shiboken6 import isValid
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
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.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
open_settings,
|
||||
profile_path,
|
||||
read_manifest,
|
||||
set_profile_readonly,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
|
||||
WorkspaceConnection,
|
||||
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.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
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
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class DockSettingsDialog(QDialog):
|
||||
|
||||
def __init__(self, parent: QWidget, target: QWidget):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Dock Settings")
|
||||
self.setModal(True)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Property editor
|
||||
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
|
||||
layout.addWidget(self.prop_editor)
|
||||
|
||||
|
||||
class SaveProfileDialog(QDialog):
|
||||
"""Dialog for saving workspace profiles with read-only option."""
|
||||
|
||||
def __init__(self, parent: QWidget, current_name: str = ""):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Save Workspace Profile")
|
||||
self.setModal(True)
|
||||
self.resize(400, 150)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Name input
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Profile Name:"))
|
||||
self.name_edit = QLineEdit(current_name)
|
||||
self.name_edit.setPlaceholderText("Enter profile name...")
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# Read-only checkbox
|
||||
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
|
||||
layout.addWidget(self.readonly_checkbox)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
|
||||
info_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch(1)
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.setDefault(True)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
self.save_btn.clicked.connect(self.accept)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(self.save_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# Enable/disable save button based on name input
|
||||
self.name_edit.textChanged.connect(self._update_save_button)
|
||||
self._update_save_button()
|
||||
|
||||
def _update_save_button(self):
|
||||
"""Enable save button only when name is not empty."""
|
||||
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
|
||||
|
||||
def get_profile_name(self) -> str:
|
||||
"""Get the entered profile name."""
|
||||
return self.name_edit.text().strip()
|
||||
|
||||
def is_readonly(self) -> bool:
|
||||
"""Check if the profile should be marked as read-only."""
|
||||
return self.readonly_checkbox.isChecked()
|
||||
|
||||
|
||||
class AdvancedDockArea(BECWidget, QWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = [
|
||||
"new",
|
||||
"widget_map",
|
||||
"widget_list",
|
||||
"lock_workspace",
|
||||
"attach_all",
|
||||
"delete_all",
|
||||
"mode",
|
||||
"mode.setter",
|
||||
]
|
||||
|
||||
# Define a signal for mode changes
|
||||
mode_changed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
mode: str = "developer",
|
||||
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Title (as a top-level QWidget it can have a window title)
|
||||
self.setWindowTitle("Advanced Dock Area")
|
||||
|
||||
# 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)
|
||||
|
||||
# Init Dock Manager
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
|
||||
# Dock manager helper variables
|
||||
self._locked = False # Lock state of the workspace
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
self._mode = "developer"
|
||||
self._default_add_direction = (
|
||||
default_add_direction
|
||||
if default_add_direction in ("left", "right", "top", "bottom")
|
||||
else "right"
|
||||
)
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self._setup_toolbar()
|
||||
self._hook_toolbar()
|
||||
|
||||
# Place toolbar and dock manager into layout
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
self._root_layout.addWidget(self.dock_manager, 1)
|
||||
|
||||
# Populate and hook the workspace combo
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(self)
|
||||
|
||||
# Developer mode state
|
||||
self._editable = None
|
||||
# Initialize default editable state based on current lock
|
||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||
|
||||
# Sync Developer toggle icon state after initial setup
|
||||
dev_action = self.toolbar.components.get_action("developer_mode").action
|
||||
dev_action.setChecked(self._editable)
|
||||
|
||||
# Apply the requested mode after everything is set up
|
||||
self.mode = mode
|
||||
|
||||
def _make_dock(
|
||||
self,
|
||||
widget: QWidget,
|
||||
*,
|
||||
closable: bool,
|
||||
floatable: bool,
|
||||
movable: bool = True,
|
||||
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
start_floating: bool = False,
|
||||
) -> CDockWidget:
|
||||
dock = CDockWidget(widget.objectName())
|
||||
dock.setWidget(widget)
|
||||
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
|
||||
|
||||
self._install_dock_settings_action(dock, widget)
|
||||
|
||||
def on_dock_close():
|
||||
widget.close()
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
def on_widget_destroyed():
|
||||
if not isValid(dock):
|
||||
return
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
dock.closeRequested.connect(on_dock_close)
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||
self.dock_manager.addDockWidget(area, dock)
|
||||
if start_floating:
|
||||
dock.setFloating()
|
||||
return dock
|
||||
|
||||
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
|
||||
).action
|
||||
action.setToolTip("Dock settings")
|
||||
action.setObjectName("dockSettingsAction")
|
||||
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
|
||||
dock.setTitleBarActions([action])
|
||||
dock.setting_action = action
|
||||
|
||||
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
dlg = DockSettingsDialog(self, widget)
|
||||
dlg.resize(600, 600)
|
||||
dlg.exec()
|
||||
|
||||
def _apply_dock_lock(self, locked: bool) -> None:
|
||||
if locked:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally()
|
||||
else:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
|
||||
|
||||
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()
|
||||
|
||||
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
|
||||
"""Return ADS DockWidgetArea from a human-friendly direction string.
|
||||
If *where* is None, fall back to instance default.
|
||||
"""
|
||||
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
|
||||
mapping = {
|
||||
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
|
||||
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
|
||||
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||
}
|
||||
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
|
||||
|
||||
################################################################################
|
||||
# Toolbar Setup
|
||||
################################################################################
|
||||
|
||||
def _setup_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
|
||||
PLOT_ACTIONS = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
"scatter_waveform": (
|
||||
ScatterWaveform.ICON_NAME,
|
||||
"Add Scatter Waveform",
|
||||
"ScatterWaveform",
|
||||
),
|
||||
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
||||
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||
}
|
||||
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"),
|
||||
"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"),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
self.toolbar.components.add_safe(
|
||||
key,
|
||||
ExpandableMenuAction(
|
||||
label=label,
|
||||
actions={
|
||||
k: MaterialIconAction(
|
||||
icon_name=v[0], tooltip=v[1], filled=True, parent=self
|
||||
)
|
||||
for k, v in mapping.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
b = ToolbarBundle(key, self.toolbar.components)
|
||||
b.add_action(key)
|
||||
self.toolbar.add_bundle(b)
|
||||
|
||||
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
|
||||
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
|
||||
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||
|
||||
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
|
||||
# Create individual action for each widget type
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
self.toolbar.components.add_safe(
|
||||
flat_action_id,
|
||||
MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
|
||||
),
|
||||
)
|
||||
bundle.add_action(flat_action_id)
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
_build_flat_bundles("plots", PLOT_ACTIONS)
|
||||
_build_flat_bundles("devices", DEVICE_ACTIONS)
|
||||
_build_flat_bundles("utils", UTIL_ACTIONS)
|
||||
|
||||
# Workspace
|
||||
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
|
||||
spacer = QWidget(parent=self.toolbar.components.toolbar)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
spacer_bundle.add_action("spacer")
|
||||
self.toolbar.add_bundle(spacer_bundle)
|
||||
|
||||
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
# Dock actions
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"screenshot",
|
||||
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
|
||||
)
|
||||
# Developer mode toggle (moved from menu into toolbar)
|
||||
self.toolbar.components.add_safe(
|
||||
"developer_mode",
|
||||
MaterialIconAction(
|
||||
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
bda = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bda.add_action("attach_all")
|
||||
bda.add_action("screenshot")
|
||||
bda.add_action("dark_mode")
|
||||
bda.add_action("developer_mode")
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
# Default bundle configuration (show menus by default)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": PLOT_ACTIONS,
|
||||
"menu_devices": DEVICE_ACTIONS,
|
||||
"menu_utils": UTIL_ACTIONS,
|
||||
}
|
||||
|
||||
def _hook_toolbar(self):
|
||||
|
||||
def _connect_menu(menu_key: str):
|
||||
menu = self.toolbar.components.get_action(menu_key)
|
||||
mapping = self._ACTION_MAPPINGS[menu_key]
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
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))
|
||||
|
||||
_connect_menu("menu_plots")
|
||||
_connect_menu("menu_devices")
|
||||
_connect_menu("menu_utils")
|
||||
|
||||
# Connect flat toolbar actions
|
||||
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||
if widget_type == "LogPanel":
|
||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
|
||||
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
|
||||
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
|
||||
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
# Developer mode toggle
|
||||
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
|
||||
self._on_developer_mode_toggled
|
||||
)
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.lock_workspace = not editable
|
||||
self._editable = editable
|
||||
|
||||
# Sync the toolbar lock toggle with current mode
|
||||
lock_action = self.toolbar.components.get_action("lock").action
|
||||
lock_action.setChecked(not editable)
|
||||
lock_action.setVisible(editable)
|
||||
|
||||
attach_all_action = self.toolbar.components.get_action("attach_all").action
|
||||
attach_all_action.setVisible(editable)
|
||||
|
||||
# Show full creation menus only when editable; otherwise keep minimal set
|
||||
if editable:
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
else:
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
# Keep Developer mode UI in sync
|
||||
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
|
||||
|
||||
def _on_developer_mode_toggled(self, checked: bool) -> None:
|
||||
"""Handle developer mode checkbox toggle."""
|
||||
self._set_editable(checked)
|
||||
|
||||
################################################################################
|
||||
# Adding widgets
|
||||
################################################################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
closable: bool = True,
|
||||
floatable: bool = True,
|
||||
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.
|
||||
|
||||
Args:
|
||||
widget: Widget instance or a string widget type (factory-created).
|
||||
closable: Whether the dock is closable.
|
||||
floatable: Whether the dock is floatable.
|
||||
movable: Whether the dock is movable.
|
||||
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.
|
||||
"""
|
||||
target_area = self._area_from_where(where)
|
||||
|
||||
# 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.name_established.connect(
|
||||
lambda: self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
)
|
||||
return widget
|
||||
|
||||
# If a widget instance is passed, dock it immediately
|
||||
self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
return widget
|
||||
|
||||
def _create_dock_with_name(
|
||||
self,
|
||||
widget: BECWidget,
|
||||
closable: bool = True,
|
||||
floatable: bool = False,
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
area: QtAds.DockWidgetArea | None = None,
|
||||
):
|
||||
target_area = area or self._area_from_where(None)
|
||||
self._make_dock(
|
||||
widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
area=target_area,
|
||||
start_floating=start_floating,
|
||||
)
|
||||
self.dock_manager.setFocus()
|
||||
|
||||
################################################################################
|
||||
# Dock Management
|
||||
################################################################################
|
||||
|
||||
def dock_map(self) -> dict[str, CDockWidget]:
|
||||
"""
|
||||
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to their corresponding dock widgets.
|
||||
"""
|
||||
return self.dock_manager.dockWidgetsMap()
|
||||
|
||||
def dock_list(self) -> list[CDockWidget]:
|
||||
"""
|
||||
Return the list of dock widgets.
|
||||
|
||||
Returns:
|
||||
list: A list of all dock widgets in the dock area.
|
||||
"""
|
||||
return self.dock_manager.dockWidgets()
|
||||
|
||||
def widget_map(self) -> dict[str, QWidget]:
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to BECWidget instances.
|
||||
"""
|
||||
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||
|
||||
def widget_list(self) -> list[QWidget]:
|
||||
"""
|
||||
Return a list of all BECWidget instances in the dock area.
|
||||
|
||||
Returns:
|
||||
list: A list of all BECWidget instances in the dock area.
|
||||
"""
|
||||
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||
"""
|
||||
for container in self.dock_manager.floatingWidgets():
|
||||
docks = container.dockWidgets()
|
||||
if not docks:
|
||||
continue
|
||||
target = docks[0]
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
|
||||
for d in docks[1:]:
|
||||
self.dock_manager.addDockWidgetTab(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_all(self):
|
||||
"""Delete all docks and widgets."""
|
||||
for dock in list(self.dock_manager.dockWidgets()):
|
||||
self._delete_dock(dock)
|
||||
|
||||
################################################################################
|
||||
# Workspace Management
|
||||
################################################################################
|
||||
@SafeProperty(bool)
|
||||
def lock_workspace(self) -> bool:
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
Returns:
|
||||
bool: True if the workspace is locked, False otherwise.
|
||||
"""
|
||||
return self._locked
|
||||
|
||||
@lock_workspace.setter
|
||||
def lock_workspace(self, value: bool):
|
||||
"""
|
||||
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
|
||||
|
||||
Args:
|
||||
value (bool): True to lock the workspace, False to unlock it.
|
||||
"""
|
||||
self._locked = value
|
||||
self._apply_dock_lock(value)
|
||||
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
|
||||
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
|
||||
for dock in self.dock_list():
|
||||
dock.setting_action.setVisible(not value)
|
||||
|
||||
@SafeSlot(str)
|
||||
def save_profile(self, name: str | None = None):
|
||||
"""
|
||||
Save the current workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
if not name:
|
||||
# Use the new SaveProfileDialog instead of QInputDialog
|
||||
dialog = SaveProfileDialog(self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check if profile already exists and is read-only
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
suggested_name = f"{name}_custom"
|
||||
reply = QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
|
||||
f"Would you like to save it with a different name?\n"
|
||||
f"Suggested name: '{suggested_name}'",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
# Show dialog again with suggested name pre-filled
|
||||
dialog = SaveProfileDialog(self, suggested_name)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check again if the new name is also read-only (recursive protection)
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
return self.save_profile()
|
||||
else:
|
||||
return
|
||||
else:
|
||||
# If name is provided directly, assume not read-only unless already exists
|
||||
readonly = False
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Display saving placeholder
|
||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
workspace_combo.blockSignals(True)
|
||||
workspace_combo.insertItem(0, f"{name}-saving")
|
||||
workspace_combo.setCurrentIndex(0)
|
||||
workspace_combo.blockSignals(False)
|
||||
|
||||
# Save the profile
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
|
||||
settings.setValue(
|
||||
SETTINGS_KEYS["state"], b""
|
||||
) # No QMainWindow state; placeholder for backward compat
|
||||
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
|
||||
self.dock_manager.addPerspective(name)
|
||||
self.dock_manager.savePerspectives(settings)
|
||||
self.state_manager.save_state(settings=settings)
|
||||
write_manifest(settings, self.dock_list())
|
||||
|
||||
# Set read-only status if specified
|
||||
if readonly:
|
||||
set_profile_readonly(name, readonly)
|
||||
|
||||
settings.sync()
|
||||
self._refresh_workspace_list()
|
||||
workspace_combo.setCurrentText(name)
|
||||
|
||||
def load_profile(self, name: str | None = None):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
# FIXME this has to be tweaked
|
||||
if not name:
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
settings = open_settings(name)
|
||||
|
||||
for item in read_manifest(settings):
|
||||
obj_name = item["object_name"]
|
||||
widget_class = item["widget_class"]
|
||||
if obj_name not in self.widget_map():
|
||||
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
|
||||
w.setObjectName(obj_name)
|
||||
self._make_dock(
|
||||
w,
|
||||
closable=item["closable"],
|
||||
floatable=item["floatable"],
|
||||
movable=item["movable"],
|
||||
area=QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
)
|
||||
|
||||
geom = settings.value(SETTINGS_KEYS["geom"])
|
||||
if geom:
|
||||
self.restoreGeometry(geom)
|
||||
# No window state for QWidget-based host; keep for backwards compat read
|
||||
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
|
||||
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
|
||||
if dock_state:
|
||||
self.dock_manager.restoreState(dock_state)
|
||||
self.dock_manager.loadPerspectives(settings)
|
||||
self.state_manager.load_state(settings=settings)
|
||||
self._set_editable(self._editable)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_profile(self):
|
||||
"""
|
||||
Delete the currently selected workspace profile file and refresh the combo list.
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
name = combo.currentText()
|
||||
if not name:
|
||||
return
|
||||
|
||||
# Check if profile is read-only
|
||||
if is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
|
||||
f"Read-only profiles are protected from modification and deletion.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Confirm deletion for regular profiles
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||
f"This action cannot be undone.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
file_path = profile_path(name)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self._refresh_workspace_list()
|
||||
|
||||
def _refresh_workspace_list(self):
|
||||
"""
|
||||
Populate the workspace combo box with all saved profile names (without .ini).
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
if hasattr(combo, "refresh_profiles"):
|
||||
combo.refresh_profiles()
|
||||
else:
|
||||
# Fallback for regular QComboBox
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(list_profiles())
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Mode Switching
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, new_mode: str):
|
||||
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
|
||||
raise ValueError(f"Invalid mode: {new_mode}")
|
||||
self._mode = new_mode
|
||||
self.mode_changed.emit(new_mode)
|
||||
|
||||
# Update toolbar visibility based on mode
|
||||
if new_mode == "user":
|
||||
# User mode: show only essential tools
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
elif new_mode == "developer":
|
||||
# Developer mode: show all tools (use menu bundles)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
elif new_mode in ["plot", "device", "utils"]:
|
||||
# Specific modes: show flat toolbar for that category
|
||||
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
|
||||
self.toolbar.show_bundles([bundle_name])
|
||||
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
|
||||
else:
|
||||
# Fallback to user mode
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
self.toolbar.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
ads = AdvancedDockArea(mode="developer", root_widget=True)
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
window.resize(800, 600)
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
|
||||
from PySide6QtAds import CDockWidget
|
||||
from qtpy.QtCore import QSettings
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
|
||||
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
|
||||
|
||||
|
||||
def profiles_dir() -> str:
|
||||
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def profile_path(name: str) -> str:
|
||||
return os.path.join(profiles_dir(), f"{name}.ini")
|
||||
|
||||
|
||||
SETTINGS_KEYS = {
|
||||
"geom": "mainWindow/Geometry",
|
||||
"state": "mainWindow/State",
|
||||
"ads_state": "mainWindow/DockingState",
|
||||
"manifest": "manifest/widgets",
|
||||
"readonly": "profile/readonly",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
|
||||
|
||||
|
||||
def is_profile_readonly(name: str) -> bool:
|
||||
"""Check if a profile is marked as read-only."""
|
||||
settings = open_settings(name)
|
||||
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
|
||||
|
||||
|
||||
def set_profile_readonly(name: str, readonly: bool) -> None:
|
||||
"""Set the read-only status of a profile."""
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
|
||||
settings.sync()
|
||||
|
||||
|
||||
def open_settings(name: str) -> QSettings:
|
||||
return QSettings(profile_path(name), QSettings.IniFormat)
|
||||
|
||||
|
||||
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
|
||||
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
|
||||
for i, dock in enumerate(docks):
|
||||
settings.setArrayIndex(i)
|
||||
w = dock.widget()
|
||||
settings.setValue("object_name", w.objectName())
|
||||
settings.setValue("widget_class", w.__class__.__name__)
|
||||
settings.setValue("closable", getattr(dock, "_default_closable", True))
|
||||
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
|
||||
settings.setValue("movable", getattr(dock, "_default_movable", True))
|
||||
settings.endArray()
|
||||
|
||||
|
||||
def read_manifest(settings: QSettings) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
|
||||
for i in range(count):
|
||||
settings.setArrayIndex(i)
|
||||
items.append(
|
||||
{
|
||||
"object_name": settings.value("object_name"),
|
||||
"widget_class": settings.value("widget_class"),
|
||||
"closable": settings.value("closable", type=bool),
|
||||
"floatable": settings.value("floatable", type=bool),
|
||||
"movable": settings.value("movable", type=bool),
|
||||
}
|
||||
)
|
||||
settings.endArray()
|
||||
return items
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,182 @@
|
||||
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
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
)
|
||||
|
||||
|
||||
class ProfileComboBox(QComboBox):
|
||||
"""Custom combobox that displays icons for read-only profiles."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
def refresh_profiles(self):
|
||||
"""Refresh the profile list with appropriate icons."""
|
||||
|
||||
current_text = self.currentText()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
|
||||
lock_icon = material_icon("edit_off", size=(16, 16), icon_type=QIcon)
|
||||
|
||||
for profile in list_profiles():
|
||||
if is_profile_readonly(profile):
|
||||
self.addItem(lock_icon, f"{profile}")
|
||||
# Set tooltip for read-only profiles
|
||||
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
|
||||
else:
|
||||
self.addItem(profile)
|
||||
|
||||
# Restore selection if possible
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
self.blockSignals(False)
|
||||
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The workspace toolbar bundle.
|
||||
"""
|
||||
# Lock icon action
|
||||
components.add_safe(
|
||||
"lock",
|
||||
MaterialIconAction(
|
||||
icon_name="lock_open_right",
|
||||
tooltip="Lock Workspace",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
# Workspace combo
|
||||
combo = ProfileComboBox(parent=components.toolbar)
|
||||
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
|
||||
|
||||
# Save the current workspace icon
|
||||
components.add_safe(
|
||||
"save_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="save",
|
||||
tooltip="Save Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"refresh_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="refresh",
|
||||
tooltip="Refresh Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"delete_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="delete",
|
||||
tooltip="Delete Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("workspace", components)
|
||||
bundle.add_action("lock")
|
||||
bundle.add_action("workspace_combo")
|
||||
bundle.add_action("save_workspace")
|
||||
bundle.add_action("refresh_workspace")
|
||||
bundle.add_action("delete_workspace")
|
||||
return bundle
|
||||
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
super().__init__(parent=components.toolbar)
|
||||
self.bundle_name = "workspace"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "lock_workspace"):
|
||||
raise AttributeError("Target widget must implement 'lock_workspace'.")
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.connect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.connect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.connect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.disconnect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.disconnect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.disconnect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _lock_workspace(self, value: bool):
|
||||
"""
|
||||
Switches the workspace lock state and change the icon accordingly.
|
||||
"""
|
||||
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)
|
||||
self.components.get_action("lock").action.setIcon(icon)
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh_workspace(self):
|
||||
"""
|
||||
Refreshes the current workspace.
|
||||
"""
|
||||
combo = self.components.get_action("workspace_combo").widget
|
||||
current_workspace = combo.currentText()
|
||||
self.target_widget.load_profile(current_workspace)
|
||||
@@ -37,7 +37,6 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
@@ -186,12 +185,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"device_browser": MaterialIconAction(
|
||||
icon_name=DeviceBrowser.ICON_NAME,
|
||||
tooltip="Add Device Browser",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -319,9 +312,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
menu_devices.actions["positioner_box"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
menu_devices.actions["device_browser"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="DeviceBrowser")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
menu_utils.actions["queue"].action.triggered.connect(
|
||||
@@ -626,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||
dock_1.new(widget="DarkModeButton")
|
||||
|
||||
@@ -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
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtGui import QDrag, QIcon
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
@@ -24,7 +24,14 @@ 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):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title="",
|
||||
indentation=10,
|
||||
show_add_button=False,
|
||||
tooltip: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
@@ -50,6 +57,8 @@ 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
|
||||
|
||||
@@ -57,13 +66,16 @@ class CollapsibleSection(QWidget):
|
||||
header_layout.addWidget(self.header_button)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.header_add_button = QPushButton()
|
||||
# Add button in header (icon-only)
|
||||
self.header_add_button = QToolButton()
|
||||
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.header_add_button.setFixedSize(20, 20)
|
||||
self.header_add_button.setFixedSize(28, 28)
|
||||
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=(20, 20)))
|
||||
self.header_add_button.setIcon(material_icon("add", size=(28, 28)))
|
||||
header_layout.addWidget(self.header_add_button)
|
||||
|
||||
self.main_layout.addLayout(header_layout)
|
||||
@@ -88,7 +100,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), convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name=icon_name, size=(20, 20), icon_type=QIcon)
|
||||
|
||||
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):
|
||||
super().__init__(parent)
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
# Main layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
|
||||
125
bec_widgets/widgets/containers/explorer/explorer_delegate.py
Normal file
125
bec_widgets/widgets/containers/explorer/explorer_delegate.py
Normal file
@@ -0,0 +1,125 @@
|
||||
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
|
||||
382
bec_widgets/widgets/containers/explorer/macro_tree_widget.py
Normal file
382
bec_widgets/widgets/containers/explorer/macro_tree_widget.py
Normal file
@@ -0,0 +1,382 @@
|
||||
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,32 +2,29 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal
|
||||
from qtpy.QtWidgets import QFileSystemModel, 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(QStyledItemDelegate):
|
||||
class FileItemDelegate(ExplorerDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
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 __init__(self, tree_widget):
|
||||
super().__init__(tree_widget)
|
||||
self.file_actions = []
|
||||
self.dir_actions = []
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
def add_file_action(self, action) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
def add_dir_action(self, action) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
@@ -36,126 +33,18 @@ class FileItemDelegate(QStyledItemDelegate):
|
||||
self.file_actions.clear()
|
||||
self.dir_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
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
|
||||
def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None:
|
||||
"""Get actions for the current item based on its type"""
|
||||
if not isinstance(model, QSortFilterProxyModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
return None
|
||||
|
||||
source_index = model.mapToSource(index)
|
||||
source_model = model.sourceModel()
|
||||
|
||||
# Early return if not a file system model
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
return None
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
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
|
||||
return self.dir_actions if is_dir else self.file_actions
|
||||
|
||||
|
||||
class ScriptTreeWidget(QWidget):
|
||||
@@ -229,12 +118,18 @@ 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;
|
||||
@@ -286,14 +181,14 @@ class ScriptTreeWidget(QWidget):
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
def set_directory(self, directory: str) -> None:
|
||||
"""Set the scripts directory"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
if not directory or not isinstance(directory, str) or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
self.directory = directory
|
||||
|
||||
root_index = self.model.setRootPath(directory)
|
||||
# Map the source model index to proxy model index
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
@@ -357,11 +252,11 @@ class ScriptTreeWidget(QWidget):
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
def add_file_action(self, action) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
def add_dir_action(self, action) -> 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),
|
||||
convert_to_pixmap=False,
|
||||
icon_type=QtGui.QIcon,
|
||||
)
|
||||
)
|
||||
icon_btn.setIconSize(QtCore.QSize(24, 24))
|
||||
@@ -901,7 +901,7 @@ class NotificationIndicator(QWidget):
|
||||
color=SEVERITY[sev.value]["color"],
|
||||
filled=True,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
icon_type=QIcon,
|
||||
)
|
||||
b.setIcon(icon)
|
||||
b.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
|
||||
|
||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||
@@ -357,7 +357,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
########################################
|
||||
# Theme menu
|
||||
theme_menu = menu_bar.addMenu("Theme")
|
||||
theme_menu = menu_bar.addMenu("View")
|
||||
|
||||
theme_group = QActionGroup(self)
|
||||
light_theme_action = QAction("Light Theme", self, checkable=True)
|
||||
@@ -374,11 +374,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
# Set the default theme
|
||||
theme = self.app.theme.theme
|
||||
if theme == "light":
|
||||
light_theme_action.setChecked(True)
|
||||
elif theme == "dark":
|
||||
dark_theme_action.setChecked(True)
|
||||
if hasattr(self.app, "theme") and self.app.theme:
|
||||
theme_name = self.app.theme.theme.lower()
|
||||
if "light" in theme_name:
|
||||
light_theme_action.setChecked(True)
|
||||
elif "dark" in theme_name:
|
||||
dark_theme_action.setChecked(True)
|
||||
|
||||
########################################
|
||||
# Help menu
|
||||
@@ -448,7 +449,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
Args:
|
||||
theme(str): Either "light" or "dark".
|
||||
"""
|
||||
set_theme(theme) # emits theme_updated and applies palette globally
|
||||
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
|
||||
@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "cancel"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
|
||||
else:
|
||||
self.button = QPushButton()
|
||||
self.button.setText("Abort")
|
||||
self.button.setStyleSheet(
|
||||
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.clicked.connect(self.abort_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -11,7 +12,7 @@ class ResetButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "restart_alt"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -23,9 +24,7 @@ class ResetButton(BECWidget, QWidget):
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
if toolbar:
|
||||
icon = material_icon(
|
||||
"restart_alt", color="#F19E39", filled=True, convert_to_pixmap=False
|
||||
)
|
||||
icon = material_icon("restart_alt", color="#F19E39", filled=True, icon_type=QIcon)
|
||||
self.button = QToolButton(icon=icon)
|
||||
self.button.setToolTip("Reset the scan queue")
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -24,7 +25,7 @@ class ResumeButton(BECWidget, QWidget):
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
if toolbar:
|
||||
icon = material_icon("resume", color="#2793e8", filled=True, convert_to_pixmap=False)
|
||||
icon = material_icon("resume", color="#2793e8", filled=True, icon_type=QIcon)
|
||||
self.button = QToolButton(icon=icon)
|
||||
self.button.setToolTip("Resume the scan queue")
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -11,7 +12,7 @@ class StopButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "dangerous"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -24,16 +25,14 @@ class StopButton(BECWidget, QWidget):
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
if toolbar:
|
||||
icon = material_icon("stop", color="#cc181e", filled=True, convert_to_pixmap=False)
|
||||
icon = material_icon("stop", color="#cc181e", filled=True, icon_type=QIcon)
|
||||
self.button = QToolButton(icon=icon)
|
||||
self.button.setToolTip("Stop the scan queue")
|
||||
else:
|
||||
self.button = QPushButton()
|
||||
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.button.setText("Stop")
|
||||
self.button.setStyleSheet(
|
||||
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.setProperty("variant", "danger")
|
||||
self.button.clicked.connect(self.stop_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -8,11 +8,11 @@ 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
|
||||
from qtpy.QtGui import QDoubleValidator, QIcon
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
|
||||
USER_ACCESS = ["set_positioner", "screenshot"]
|
||||
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
@@ -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), convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), icon_type=QIcon)
|
||||
self.ui.tool_button.setIcon(icon)
|
||||
|
||||
def force_update_readback(self):
|
||||
@@ -259,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -9,11 +9,11 @@ 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
|
||||
from qtpy.QtGui import QDoubleValidator, QIcon
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -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), convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), icon_type=QIcon)
|
||||
self.ui.tool_button_hor.setIcon(icon)
|
||||
self.ui.tool_button_ver.setIcon(icon)
|
||||
|
||||
@@ -478,7 +478,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox2D()
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "grid_view"
|
||||
USER_ACCESS = ["set_positioners"]
|
||||
USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"]
|
||||
|
||||
# Signal emitted to inform listeners about a position update of the first positioner
|
||||
position_update = Signal(float)
|
||||
|
||||
@@ -147,24 +147,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
dev_name = self.currentText()
|
||||
return self.get_device_object(dev_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
"""Extend the paint event to set the border color based on the validity of the input.
|
||||
|
||||
Args:
|
||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
||||
"""
|
||||
# logger.info(f"Received paint event: {event} in {self.__class__}")
|
||||
super().paintEvent(event)
|
||||
|
||||
if self._is_valid_input is False and self.isEnabled() is True:
|
||||
painter = QPainter(self)
|
||||
pen = QPen()
|
||||
pen.setWidth(2)
|
||||
pen.setColor(self._accent_colors.emergency)
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
painter.end()
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""
|
||||
@@ -173,10 +155,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text)
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.device_reset.emit()
|
||||
self.update()
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
|
||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
||||
"""
|
||||
@@ -202,10 +186,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -175,13 +175,13 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -179,10 +179,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -147,13 +147,13 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .device_table_view import DeviceTableView
|
||||
from .dm_config_view import DMConfigView
|
||||
from .dm_docstring_view import DocstringView
|
||||
from .dm_ophyd_test import DMOphydTest
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import json
|
||||
from typing import Any, Callable, Generator, Iterable, TypeVar
|
||||
|
||||
from bec_lib.utils.json import ExtendedEncoder
|
||||
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
|
||||
from qtpy.QtWidgets import QListWidgetItem
|
||||
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import (
|
||||
MIME_DEVICE_CONFIG,
|
||||
SORT_KEY_ROLE,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_RT = TypeVar("_RT")
|
||||
|
||||
|
||||
def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]:
|
||||
for v in vals:
|
||||
try:
|
||||
yield fn(v)
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData:
|
||||
"""Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG"""
|
||||
mime_obj = QMimeData()
|
||||
byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8"))
|
||||
mime_obj.setData(MIME_DEVICE_CONFIG, byte_array)
|
||||
return mime_obj
|
||||
|
||||
|
||||
class SortableQListWidgetItem(QListWidgetItem):
|
||||
"""Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with
|
||||
custom widgets and this item."""
|
||||
|
||||
def __gt__(self, other):
|
||||
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
|
||||
other_key := other.data(SORT_KEY_ROLE)
|
||||
) is None:
|
||||
return False
|
||||
return self_key.lower() > other_key.lower()
|
||||
|
||||
def __lt__(self, other):
|
||||
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
|
||||
other_key := other.data(SORT_KEY_ROLE)
|
||||
) is None:
|
||||
return False
|
||||
return self_key.lower() < other_key.lower()
|
||||
|
||||
|
||||
class SharedSelectionSignal(QObject):
|
||||
proc = Signal(str)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .available_device_resources import AvailableDeviceResources
|
||||
|
||||
__all__ = ["AvailableDeviceResources"]
|
||||
@@ -0,0 +1,230 @@
|
||||
from textwrap import dedent
|
||||
from typing import NamedTuple
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QItemSelection, QSize, Signal
|
||||
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import (
|
||||
Ui_AvailableDeviceGroup,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
|
||||
HashableDevice,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
|
||||
|
||||
|
||||
def _warning_string(spec: HashableDevice):
|
||||
name_warning = (
|
||||
"Device defined with multiple names! Please check:\n " + "\n ".join(spec.names)
|
||||
if len(spec.names) > 1
|
||||
else ""
|
||||
)
|
||||
source_warning = (
|
||||
"Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files)
|
||||
if len(spec._source_files) > 1
|
||||
else ""
|
||||
)
|
||||
return f"{name_warning}{source_warning}"
|
||||
|
||||
|
||||
class _DeviceEntryWidget(QFrame):
|
||||
|
||||
def __init__(self, device_spec: HashableDevice, parent=None, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self._device_spec = device_spec
|
||||
self.included: bool = False
|
||||
|
||||
self.setFrameStyle(0)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self.setup_title_layout(device_spec)
|
||||
self.check_and_display_warning()
|
||||
|
||||
self.setToolTip(self._rich_text())
|
||||
|
||||
def _rich_text(self):
|
||||
return dedent(
|
||||
f"""
|
||||
<b><u><h2> {self._device_spec.name}: </h2></u></b>
|
||||
<table>
|
||||
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
|
||||
<tr><td> config: </td><td><i> {self._device_spec.deviceConfig} </i></td></tr>
|
||||
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
|
||||
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
|
||||
</table>
|
||||
"""
|
||||
)
|
||||
|
||||
def setup_title_layout(self, device_spec: HashableDevice):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._title_container = QWidget(parent=self)
|
||||
self._title_container.setLayout(self._title_layout)
|
||||
|
||||
self._warning_label = QLabel()
|
||||
self._title_layout.addWidget(self._warning_label)
|
||||
|
||||
self.title = QLabel(device_spec.name)
|
||||
self.title.setToolTip(device_spec.name)
|
||||
self.title.setStyleSheet(self.title_style("#FF0000"))
|
||||
self._title_layout.addWidget(self.title)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
self._layout.addWidget(self._title_container)
|
||||
|
||||
def check_and_display_warning(self):
|
||||
if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1:
|
||||
self._warning_label.setText("")
|
||||
self._warning_label.setToolTip("")
|
||||
else:
|
||||
self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00"))
|
||||
self._warning_label.setToolTip(_warning_string(self._device_spec))
|
||||
|
||||
@property
|
||||
def device_hash(self):
|
||||
return hash(self._device_spec)
|
||||
|
||||
def title_style(self, color: str) -> str:
|
||||
return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}"
|
||||
|
||||
def setTitle(self, text: str):
|
||||
self.title.setText(text)
|
||||
|
||||
def set_included(self, included: bool):
|
||||
self.included = included
|
||||
self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000"))
|
||||
|
||||
|
||||
class _DeviceEntry(NamedTuple):
|
||||
list_item: QListWidgetItem
|
||||
widget: _DeviceEntryWidget
|
||||
|
||||
|
||||
class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
|
||||
|
||||
selected_devices = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
name: str = "TagGroupTitle",
|
||||
data: set[HashableDevice] = set(),
|
||||
shared_selection_signal=SharedSelectionSignal(),
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setupUi(self)
|
||||
|
||||
self._shared_selection_signal = shared_selection_signal
|
||||
self._shared_selection_uuid = str(uuid4())
|
||||
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
|
||||
self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
||||
|
||||
self.title_text = name # type: ignore
|
||||
self._mime_data = []
|
||||
self._devices: dict[str, _DeviceEntry] = {}
|
||||
for device in data:
|
||||
self._add_item(device)
|
||||
self.device_list.sortItems()
|
||||
self.setMinimumSize(self.device_list.sizeHint())
|
||||
self._update_num_included()
|
||||
|
||||
def _add_item(self, device: HashableDevice):
|
||||
item = QListWidgetItem(self.device_list)
|
||||
device_dump = device.model_dump(exclude_defaults=True)
|
||||
item.setData(CONFIG_DATA_ROLE, device_dump)
|
||||
self._mime_data.append(device_dump)
|
||||
widget = _DeviceEntryWidget(device, self)
|
||||
item.setSizeHint(QSize(widget.width(), widget.height()))
|
||||
self.device_list.setItemWidget(item, widget)
|
||||
self.device_list.addItem(item)
|
||||
self._devices[device.name] = _DeviceEntry(item, widget)
|
||||
|
||||
def create_mime_data(self):
|
||||
return self._mime_data
|
||||
|
||||
def reset_devices_state(self):
|
||||
for dev in self._devices.values():
|
||||
dev.widget.set_included(False)
|
||||
self._update_num_included()
|
||||
|
||||
def set_item_state(self, /, device_hash: int, included: bool):
|
||||
for dev in self._devices.values():
|
||||
if dev.widget.device_hash == device_hash:
|
||||
dev.widget.set_included(included)
|
||||
self._update_num_included()
|
||||
|
||||
def _update_num_included(self):
|
||||
n_included = sum(int(dev.widget.included) for dev in self._devices.values())
|
||||
if n_included == 0:
|
||||
color = "#FF0000"
|
||||
elif n_included == len(self._devices):
|
||||
color = "#00FF00"
|
||||
else:
|
||||
color = "#FFAA00"
|
||||
self.n_included.setText(f"{n_included} / {len(self._devices)}")
|
||||
self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}")
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
if not getattr(self, "device_list", None) or not self.expanded:
|
||||
return super().sizeHint()
|
||||
return QSize(
|
||||
max(150, self.device_list.viewport().width()),
|
||||
self.device_list.sizeHintForRow(0) * self.device_list.count() + 50,
|
||||
)
|
||||
|
||||
@SafeSlot(QItemSelection, QItemSelection)
|
||||
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
|
||||
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
|
||||
config = [dev.as_normal_device().model_dump() for dev in self.get_selection()]
|
||||
self.selected_devices.emit(config)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _handle_shared_selection_signal(self, uuid: str):
|
||||
if uuid != self._shared_selection_uuid:
|
||||
self.device_list.clearSelection()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.setMinimumHeight(self.sizeHint().height())
|
||||
self.setMaximumHeight(self.sizeHint().height())
|
||||
|
||||
def get_selection(self) -> set[HashableDevice]:
|
||||
selection = self.device_list.selectedItems()
|
||||
widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection)
|
||||
return set(w._device_spec for w in widgets)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}: {self.title_text}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = AvailableDeviceGroup(name="Tag group 1")
|
||||
for item in [
|
||||
HashableDevice(
|
||||
**{
|
||||
"name": f"test_device_{i}",
|
||||
"deviceClass": "TestDeviceClass",
|
||||
"readoutPriority": "baseline",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
for i in range(5)
|
||||
]:
|
||||
widget._add_item(item)
|
||||
widget._update_num_included()
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QMetaObject, Qt
|
||||
from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import (
|
||||
CONFIG_DATA_ROLE,
|
||||
MIME_DEVICE_CONFIG,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .available_device_group import AvailableDeviceGroup
|
||||
|
||||
|
||||
class _DeviceListWiget(QListWidget):
|
||||
|
||||
def _item_iter(self):
|
||||
return (self.item(i) for i in range(self.count()))
|
||||
|
||||
def all_configs(self):
|
||||
return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()]
|
||||
|
||||
def mimeTypes(self):
|
||||
return [MIME_DEVICE_CONFIG]
|
||||
|
||||
def mimeData(self, items):
|
||||
return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items)
|
||||
|
||||
|
||||
class Ui_AvailableDeviceGroup(object):
|
||||
def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"):
|
||||
if not AvailableDeviceGroup.objectName():
|
||||
AvailableDeviceGroup.setObjectName("AvailableDeviceGroup")
|
||||
AvailableDeviceGroup.setMinimumWidth(150)
|
||||
|
||||
self.verticalLayout = QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
AvailableDeviceGroup.set_layout(self.verticalLayout)
|
||||
|
||||
title_layout = AvailableDeviceGroup.get_title_layout()
|
||||
|
||||
self.n_included = QLabel(AvailableDeviceGroup, text="...")
|
||||
self.n_included.setObjectName("n_included")
|
||||
title_layout.addWidget(self.n_included)
|
||||
|
||||
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
|
||||
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
self.device_list.setObjectName("device_list")
|
||||
self.device_list.setFrameStyle(0)
|
||||
self.device_list.setDragEnabled(True)
|
||||
self.device_list.setAcceptDrops(False)
|
||||
self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
self.verticalLayout.addWidget(self.device_list)
|
||||
AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box)
|
||||
QMetaObject.connectSlotsByName(AvailableDeviceGroup)
|
||||
@@ -0,0 +1,128 @@
|
||||
from random import randint
|
||||
from typing import Any, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from qtpy.QtCore import QItemSelection, Signal # type: ignore
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components._util import (
|
||||
SharedSelectionSignal,
|
||||
yield_only_passing,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import (
|
||||
Ui_availableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
|
||||
HashableDevice,
|
||||
get_backend,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
|
||||
|
||||
|
||||
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
|
||||
|
||||
selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected
|
||||
add_selected_devices = Signal(list)
|
||||
del_selected_devices = Signal(list)
|
||||
|
||||
def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setupUi(self)
|
||||
self._backend = get_backend()
|
||||
self._shared_selection_signal = shared_selection_signal
|
||||
self._shared_selection_uuid = str(uuid4())
|
||||
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
|
||||
self.device_groups_list.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_changed
|
||||
)
|
||||
self.grouping_selector.addItem("deviceTags")
|
||||
self.grouping_selector.addItems(self._backend.allowed_sort_keys)
|
||||
self._grouping_selection_changed("deviceTags")
|
||||
self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed)
|
||||
self.search_box.textChanged.connect(self.device_groups_list.update_filter)
|
||||
|
||||
self.tb_add_selected.action.triggered.connect(self._add_selected_action)
|
||||
self.tb_del_selected.action.triggered.connect(self._del_selected_action)
|
||||
|
||||
def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]):
|
||||
self.device_groups_list.clear()
|
||||
for device_group, devices in device_groups.items():
|
||||
self._add_device_group(device_group, devices)
|
||||
if self.grouping_selector.currentText == "deviceTags":
|
||||
self._add_device_group("Untagged devices", self._backend.untagged_devices)
|
||||
self.device_groups_list.sortItems()
|
||||
|
||||
def _add_device_group(self, device_group: str, devices: set[HashableDevice]):
|
||||
item, widget = self.device_groups_list.add_item(
|
||||
device_group,
|
||||
self.device_groups_list,
|
||||
device_group,
|
||||
devices,
|
||||
shared_selection_signal=self._shared_selection_signal,
|
||||
expanded=False,
|
||||
)
|
||||
item.setData(CONFIG_DATA_ROLE, widget.create_mime_data())
|
||||
# Re-emit the selected items from a subgroup - all other selections should be disabled anyway
|
||||
widget.selected_devices.connect(self.selected_devices)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
for list_item, device_group_widget in self.device_groups_list.item_widget_pairs():
|
||||
list_item.setSizeHint(device_group_widget.sizeHint())
|
||||
|
||||
@SafeSlot()
|
||||
def _add_selected_action(self):
|
||||
self.add_selected_devices.emit(self.device_groups_list.any_selected_devices())
|
||||
|
||||
@SafeSlot()
|
||||
def _del_selected_action(self):
|
||||
self.del_selected_devices.emit(self.device_groups_list.any_selected_devices())
|
||||
|
||||
@SafeSlot(QItemSelection, QItemSelection)
|
||||
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
|
||||
self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups())
|
||||
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _handle_shared_selection_signal(self, uuid: str):
|
||||
if uuid != self._shared_selection_uuid:
|
||||
self.device_groups_list.clearSelection()
|
||||
|
||||
def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
|
||||
for device in devices:
|
||||
for device_group in self.device_groups_list.widgets():
|
||||
device_group.set_item_state(hash(device), included)
|
||||
|
||||
@SafeSlot(list)
|
||||
def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool):
|
||||
"""Set the display color of individual devices and update the group display of numbers
|
||||
included. Accepts a list of dicts with the complete config as used in
|
||||
bec_lib.atlas_models.Device."""
|
||||
self._set_devices_state(
|
||||
yield_only_passing(HashableDevice.model_validate, config_list), used
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _grouping_selection_changed(self, sort_key: str):
|
||||
self.search_box.setText("")
|
||||
if sort_key == "deviceTags":
|
||||
device_groups = self._backend.tag_groups
|
||||
else:
|
||||
device_groups = self._backend.group_by_key(sort_key)
|
||||
self.refresh_full_list(device_groups)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = AvailableDeviceResources()
|
||||
widget._set_devices_state(
|
||||
list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True
|
||||
)
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
from qtpy.QtCore import QMetaObject, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
||||
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.control.device_manager.components._util import mimedata_from_configs
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import (
|
||||
AvailableDeviceGroup,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import (
|
||||
CONFIG_DATA_ROLE,
|
||||
MIME_DEVICE_CONFIG,
|
||||
)
|
||||
|
||||
|
||||
class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]):
|
||||
|
||||
def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup:
|
||||
return super().itemWidget(item) # type: ignore
|
||||
|
||||
def any_selected_devices(self):
|
||||
return self.selected_individual_devices() or self.selected_devices_from_groups()
|
||||
|
||||
def selected_individual_devices(self):
|
||||
for widget in (self.itemWidget(self.item(i)) for i in range(self.count())):
|
||||
if (selected := widget.get_selection()) != set():
|
||||
return [dev.as_normal_device().model_dump() for dev in selected]
|
||||
return []
|
||||
|
||||
def selected_devices_from_groups(self):
|
||||
selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows())
|
||||
widgets = (self.itemWidget(item) for item in selected_items)
|
||||
return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets))
|
||||
|
||||
def mimeTypes(self):
|
||||
return [MIME_DEVICE_CONFIG]
|
||||
|
||||
def mimeData(self, items):
|
||||
return mimedata_from_configs(
|
||||
itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items)
|
||||
)
|
||||
|
||||
|
||||
class Ui_availableDeviceResources(object):
|
||||
def setupUi(self, availableDeviceResources):
|
||||
if not availableDeviceResources.objectName():
|
||||
availableDeviceResources.setObjectName("availableDeviceResources")
|
||||
self.verticalLayout = QVBoxLayout(availableDeviceResources)
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
|
||||
self._add_toolbar()
|
||||
|
||||
# Main area with search and filter using a grid layout
|
||||
self.search_layout = QVBoxLayout()
|
||||
self.grid_layout = QGridLayout()
|
||||
|
||||
self.grouping_selector = QComboBox()
|
||||
self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
lbl_group = QLabel("Group by:")
|
||||
lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.grid_layout.addWidget(lbl_group, 0, 0)
|
||||
self.grid_layout.addWidget(self.grouping_selector, 0, 1)
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
lbl_filter = QLabel("Filter:")
|
||||
lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.grid_layout.addWidget(lbl_filter, 1, 0)
|
||||
self.grid_layout.addWidget(self.search_box, 1, 1)
|
||||
|
||||
self.grid_layout.setColumnStretch(0, 0)
|
||||
self.grid_layout.setColumnStretch(1, 1)
|
||||
|
||||
self.search_layout.addLayout(self.grid_layout)
|
||||
self.verticalLayout.addLayout(self.search_layout)
|
||||
|
||||
self.device_groups_list = _ListOfDeviceGroups(
|
||||
availableDeviceResources, AvailableDeviceGroup
|
||||
)
|
||||
self.device_groups_list.setObjectName("device_groups_list")
|
||||
self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.device_groups_list.setMovement(QListView.Movement.Static)
|
||||
self.device_groups_list.setSpacing(4)
|
||||
self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
|
||||
self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems)
|
||||
self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
self.device_groups_list.setDragEnabled(True)
|
||||
self.device_groups_list.setAcceptDrops(False)
|
||||
self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
availableDeviceResources.setMinimumWidth(250)
|
||||
availableDeviceResources.resize(250, availableDeviceResources.height())
|
||||
|
||||
self.verticalLayout.addWidget(self.device_groups_list)
|
||||
|
||||
QMetaObject.connectSlotsByName(availableDeviceResources)
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
self.tb_add_selected = MaterialIconAction(
|
||||
icon_name="add_box", parent=self, tooltip="Add selected devices to composition"
|
||||
)
|
||||
self.toolbar.components.add_safe("add_selected", self.tb_add_selected)
|
||||
io_bundle.add_action("add_selected")
|
||||
|
||||
self.tb_del_selected = MaterialIconAction(
|
||||
icon_name="chips", parent=self, tooltip="Remove selected devices from composition"
|
||||
)
|
||||
self.toolbar.components.add_safe("del_selected", self.tb_del_selected)
|
||||
io_bundle.add_action("del_selected")
|
||||
|
||||
self.verticalLayout.addWidget(self.toolbar)
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
self.toolbar.show_bundles(["IO"])
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import os
|
||||
from enum import Enum, auto
|
||||
from functools import partial, reduce
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.atlas_models import HashableDevice, HashableDeviceSet
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# use the last n recovery files
|
||||
_N_RECOVERY_FILES = 3
|
||||
_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.."
|
||||
|
||||
|
||||
def get_backend() -> DeviceResourceBackend:
|
||||
return _ConfigFileBackend()
|
||||
|
||||
|
||||
class HashModel(str, Enum):
|
||||
DEFAULT = auto()
|
||||
DEFAULT_DEVICECONFIG = auto()
|
||||
DEFAULT_EPICS = auto()
|
||||
|
||||
|
||||
class DeviceResourceBackend(Protocol):
|
||||
@property
|
||||
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
"""A dictionary of all availble devices separated by tag groups. The same device may
|
||||
appear more than once (in different groups)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def all_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all availble devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
@property
|
||||
def untagged_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all untagged devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
@property
|
||||
def allowed_sort_keys(self) -> set[str]:
|
||||
"""A set of all fields which you may group devices by"""
|
||||
...
|
||||
|
||||
def tags(self) -> set[str]:
|
||||
"""Returns a set of all the tags in all available devices."""
|
||||
...
|
||||
|
||||
def tag_group(self, tag: str) -> set[HashableDevice]:
|
||||
"""Returns a set of the devices in the tag group with the given key."""
|
||||
...
|
||||
|
||||
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
|
||||
"""Return a dict of all devices, organised by the specified key, which must be one of
|
||||
the string keys in the Device model."""
|
||||
...
|
||||
|
||||
|
||||
def _devices_from_file(file: str, include_source: bool = True):
|
||||
data = yaml_load(file, process_includes=False)
|
||||
return HashableDeviceSet(
|
||||
HashableDevice.model_validate(
|
||||
dev | {"name": name, "source_files": {file} if include_source else set()}
|
||||
)
|
||||
for name, dev in data.items()
|
||||
)
|
||||
|
||||
|
||||
class _ConfigFileBackend(DeviceResourceBackend):
|
||||
def __init__(self) -> None:
|
||||
self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files()
|
||||
if plugins_installed() == 1:
|
||||
self._raw_device_set.update(
|
||||
self._get_configs_from_plugin_files(
|
||||
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
|
||||
)
|
||||
)
|
||||
self._device_groups = self._get_tag_groups()
|
||||
|
||||
def _get_config_from_backup_files(self):
|
||||
dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs"
|
||||
files = sorted(glob("*.yaml", root_dir=dir))
|
||||
last_n_files = files[-_N_RECOVERY_FILES:]
|
||||
return reduce(
|
||||
operator.or_,
|
||||
map(
|
||||
partial(_devices_from_file, include_source=False),
|
||||
(str(dir / f) for f in last_n_files),
|
||||
),
|
||||
set(),
|
||||
)
|
||||
|
||||
def _get_configs_from_plugin_files(self, dir: Path):
|
||||
files = glob("*.yaml", root_dir=dir, recursive=True)
|
||||
return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set())
|
||||
|
||||
def _get_tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
return {
|
||||
tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set))
|
||||
for tag in self.tags()
|
||||
}
|
||||
|
||||
@property
|
||||
def tag_groups(self):
|
||||
return self._device_groups
|
||||
|
||||
@property
|
||||
def all_devices(self):
|
||||
return self._raw_device_set
|
||||
|
||||
@property
|
||||
def untagged_devices(self):
|
||||
return {d for d in self._raw_device_set if d.deviceTags == set()}
|
||||
|
||||
@property
|
||||
def allowed_sort_keys(self) -> set[str]:
|
||||
return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str}
|
||||
|
||||
def tags(self) -> set[str]:
|
||||
return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set())
|
||||
|
||||
def tag_group(self, tag: str) -> set[HashableDevice]:
|
||||
return self.tag_groups[tag]
|
||||
|
||||
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
|
||||
if key not in self.allowed_sort_keys:
|
||||
raise ValueError(f"Cannot group available devices by model key {key}")
|
||||
group_names: set[str] = {getattr(item, key) for item in self._raw_device_set}
|
||||
return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names}
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import Final
|
||||
|
||||
# Denotes a MIME type for JSON-encoded list of device config dictionaries
|
||||
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."]),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
"""Module with a config view for the device manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
|
||||
import yaml
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DMConfigView(BECWidget, QtWidgets.QWidget):
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Monaco widget
|
||||
self.monaco_editor = MonacoWidget()
|
||||
self._customize_monaco()
|
||||
self.stacked_layout.addWidget(self.monaco_editor)
|
||||
|
||||
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_monaco(self):
|
||||
|
||||
self.monaco_editor.set_language("yaml")
|
||||
self.monaco_editor.set_vim_mode_enabled(False)
|
||||
self.monaco_editor.set_minimap_enabled(False)
|
||||
# self.monaco_editor.setFixedHeight(600)
|
||||
self.monaco_editor.set_readonly(True)
|
||||
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
|
||||
self.monaco_editor.editor.set_line_numbers_mode("off")
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def on_select_config(self, device: list[dict]):
|
||||
"""Handle selection of a device from the device table."""
|
||||
if len(device) != 1:
|
||||
text = ""
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
else:
|
||||
try:
|
||||
text = yaml.dump(device[0], default_flow_style=False)
|
||||
self.stacked_layout.setCurrentWidget(self.monaco_editor)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error converting device to YAML:\n{content}")
|
||||
text = ""
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
self.monaco_editor.set_readonly(False) # Enable editing
|
||||
text = text.rstrip()
|
||||
self.monaco_editor.set_text(text)
|
||||
self.monaco_editor.set_readonly(True) # Disable editing again
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
config_view = DMConfigView()
|
||||
layout.addWidget(config_view)
|
||||
combo_box = QtWidgets.QComboBox()
|
||||
config = config_view.client.device_manager._get_redis_device_config()
|
||||
combo_box.addItems([""] + [str(v) for v, item in enumerate(config)])
|
||||
|
||||
def on_select(text):
|
||||
if text == "":
|
||||
config_view.on_select_config([])
|
||||
else:
|
||||
config_view.on_select_config([config[int(text)]])
|
||||
|
||||
combo_box.currentTextChanged.connect(on_select)
|
||||
layout.addWidget(combo_box)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Module to visualize the docstring of a device class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
import traceback
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
|
||||
from bec_lib.utils.rpc_utils import rgetattr
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
try:
|
||||
import ophyd
|
||||
import ophyd_devices
|
||||
|
||||
READY_TO_VIEW = True
|
||||
except ImportError:
|
||||
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||
ophyd_devices = None
|
||||
ophyd = None
|
||||
|
||||
|
||||
def docstring_to_markdown(obj) -> str:
|
||||
"""
|
||||
Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown.
|
||||
"""
|
||||
raw = inspect.getdoc(obj) or "*No docstring available.*"
|
||||
|
||||
# Dedent and normalize newlines
|
||||
text = textwrap.dedent(raw).strip()
|
||||
|
||||
md = ""
|
||||
if hasattr(obj, "__name__"):
|
||||
md += f"# {obj.__name__}\n\n"
|
||||
|
||||
# Highlight section headers for Markdown
|
||||
headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"]
|
||||
for h in headers:
|
||||
text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text)
|
||||
|
||||
# Preserve code blocks (4+ space indented lines)
|
||||
def fence_code(match: re.Match) -> str:
|
||||
block = re.sub(r"^ {4}", "", match.group(0), flags=re.M)
|
||||
return f"```\n{block}\n```"
|
||||
|
||||
doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text)
|
||||
|
||||
# Preserve normal line breaks for Markdown
|
||||
lines = doc.splitlines()
|
||||
processed_lines = []
|
||||
for line in lines:
|
||||
if line.strip() == "":
|
||||
processed_lines.append("")
|
||||
else:
|
||||
processed_lines.append(line + " ")
|
||||
doc = "\n".join(processed_lines)
|
||||
|
||||
md += doc
|
||||
return md
|
||||
|
||||
|
||||
class DocstringView(QtWidgets.QTextEdit):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setReadOnly(True)
|
||||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
|
||||
if not READY_TO_VIEW:
|
||||
self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.")
|
||||
self.setEnabled(False)
|
||||
return
|
||||
|
||||
def _set_text(self, text: str):
|
||||
self.setReadOnly(False)
|
||||
self.setMarkdown(text)
|
||||
self.setReadOnly(True)
|
||||
|
||||
@SafeSlot(list)
|
||||
def on_select_config(self, device: list[dict]):
|
||||
if len(device) != 1:
|
||||
self._set_text("")
|
||||
return
|
||||
device_class = device[0].get("deviceClass", "")
|
||||
self.set_device_class(device_class)
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device_class(self, device_class_str: str) -> None:
|
||||
if not READY_TO_VIEW:
|
||||
return
|
||||
try:
|
||||
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
|
||||
markdown = docstring_to_markdown(module_cls)
|
||||
self._set_text(markdown)
|
||||
except Exception:
|
||||
logger.exception("Error retrieving docstring")
|
||||
self._set_text(f"*Error retrieving docstring for `{device_class_str}`*")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
config_view = DocstringView()
|
||||
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
|
||||
layout.addWidget(config_view)
|
||||
combo = QtWidgets.QComboBox()
|
||||
combo.addItems(
|
||||
[
|
||||
"",
|
||||
"ophyd_devices.sim.sim_camera.SimCamera",
|
||||
"ophyd.EpicsSignalWithRBV",
|
||||
"ophyd.EpicsMotor",
|
||||
"csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS",
|
||||
]
|
||||
)
|
||||
combo.currentTextChanged.connect(config_view.set_device_class)
|
||||
layout.addWidget(combo)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Module to run a static tests for devices from a yaml config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import re
|
||||
from collections import deque
|
||||
from concurrent.futures import CancelledError, Future, ThreadPoolExecutor
|
||||
from html import escape
|
||||
from threading import Event, RLock
|
||||
from typing import Any, Iterable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
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.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
READY_TO_TEST = False
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
try:
|
||||
import bec_server
|
||||
import ophyd_devices
|
||||
|
||||
READY_TO_TEST = True
|
||||
except ImportError:
|
||||
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||
ophyd_devices = None
|
||||
bec_server = None
|
||||
|
||||
try:
|
||||
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||
except ImportError:
|
||||
StaticDeviceTest = None
|
||||
|
||||
|
||||
class ValidationStatus(int, enum.Enum):
|
||||
"""Validation status for device configurations."""
|
||||
|
||||
PENDING = 0 # colors.default
|
||||
VALID = 1 # colors.highlight
|
||||
FAILED = 2 # colors.emergency
|
||||
|
||||
|
||||
class DeviceValidationResult(QtCore.QObject):
|
||||
"""Simple object to inject validation signals into QRunnable."""
|
||||
|
||||
# Device validation signal, device_name, ValidationStatus as int, error message or ''
|
||||
device_validated = QtCore.Signal(str, bool, str)
|
||||
|
||||
|
||||
class DeviceTester(QtCore.QRunnable):
|
||||
def __init__(self, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.signals = DeviceValidationResult()
|
||||
self.shutdown_event = Event()
|
||||
|
||||
self._config = config
|
||||
|
||||
self._max_threads = 4
|
||||
self._pending_event = Event()
|
||||
self._lock = RLock()
|
||||
self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester")
|
||||
|
||||
self._pending_queue: deque[tuple[str, dict]] = deque([])
|
||||
self._active: set[str] = set()
|
||||
|
||||
QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set())
|
||||
|
||||
def run(self):
|
||||
if StaticDeviceTest is None:
|
||||
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
|
||||
return
|
||||
while not self.shutdown_event.is_set():
|
||||
self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s
|
||||
if len(self._active) >= self._max_threads:
|
||||
self._pending_event.clear() # it will be set again on removing something from active
|
||||
continue
|
||||
with self._lock:
|
||||
if len(self._pending_queue) > 0:
|
||||
item, cfg, connect = self._pending_queue.pop()
|
||||
self._active.add(item)
|
||||
fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect)
|
||||
fut.__dict__["__device_name"] = item
|
||||
fut.add_done_callback(self._done_cb)
|
||||
self._safe_check_and_clear()
|
||||
self._cleanup()
|
||||
|
||||
def submit(self, devices: Iterable[tuple[str, dict, bool]]):
|
||||
with self._lock:
|
||||
self._pending_queue.extend(devices)
|
||||
self._pending_event.set()
|
||||
|
||||
@staticmethod
|
||||
def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]:
|
||||
tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None
|
||||
results = tester.run_with_list_output(connect=connect)
|
||||
return name, results[0].success, results[0].message
|
||||
|
||||
def _safe_check_and_clear(self):
|
||||
with self._lock:
|
||||
if len(self._pending_queue) == 0:
|
||||
self._pending_event.clear()
|
||||
|
||||
def _safe_remove_from_active(self, name: str):
|
||||
with self._lock:
|
||||
self._active.remove(name)
|
||||
self._pending_event.set() # check again once a completed task is removed
|
||||
|
||||
def _done_cb(self, future: Future):
|
||||
try:
|
||||
name, success, message = future.result()
|
||||
except CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
name, success, message = future.__dict__["__device_name"], False, str(e)
|
||||
finally:
|
||||
self._safe_remove_from_active(future.__dict__["__device_name"])
|
||||
self.signals.device_validated.emit(name, success, message)
|
||||
|
||||
def _cleanup(self): ...
|
||||
|
||||
|
||||
class ValidationListItem(QtWidgets.QWidget):
|
||||
"""Custom list item widget showing device name and validation status."""
|
||||
|
||||
def __init__(self, device_name: str, device_config: dict, parent=None):
|
||||
"""
|
||||
Initialize the validation list item.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
device_config (dict): The configuration of the device.
|
||||
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.main_layout = QtWidgets.QHBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.main_layout.setSpacing(4)
|
||||
self.device_name = device_name
|
||||
self.device_config = device_config
|
||||
self.validation_msg = "Validation in progress..."
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the list item."""
|
||||
label = QtWidgets.QLabel(self.device_name)
|
||||
self.main_layout.addWidget(label)
|
||||
self.main_layout.addStretch()
|
||||
self._spinner = SpinnerWidget(parent=self)
|
||||
self._spinner.speed = 80
|
||||
self._spinner.setFixedSize(24, 24)
|
||||
self.main_layout.addWidget(self._spinner)
|
||||
self._base_style = "font-weight: bold;"
|
||||
self.setStyleSheet(self._base_style)
|
||||
self._start_spinner()
|
||||
|
||||
def _start_spinner(self):
|
||||
"""Start the spinner animation."""
|
||||
self._spinner.start()
|
||||
|
||||
def _stop_spinner(self):
|
||||
"""Stop the spinner animation."""
|
||||
self._spinner.stop()
|
||||
self._spinner.setVisible(False)
|
||||
|
||||
@SafeSlot()
|
||||
def on_validation_restart(self):
|
||||
"""Handle validation restart."""
|
||||
self.validation_msg = ""
|
||||
self._start_spinner()
|
||||
self.setStyleSheet("") # Check if this works as expected
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_validation_failed(self, error_msg: str):
|
||||
"""Handle validation failure."""
|
||||
self.validation_msg = error_msg
|
||||
colors = get_accent_colors()
|
||||
self._stop_spinner()
|
||||
self.main_layout.removeWidget(self._spinner)
|
||||
self._spinner.deleteLater()
|
||||
label = QtWidgets.QLabel("")
|
||||
icon = material_icon("error", color=colors.emergency, size=(24, 24))
|
||||
label.setPixmap(icon)
|
||||
self.main_layout.addWidget(label)
|
||||
|
||||
|
||||
class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
"""Widget to test device configurations using ophyd devices."""
|
||||
|
||||
# Signal to emit the validation status of a device
|
||||
device_validated = QtCore.Signal(str, int)
|
||||
# validation_msg in markdown format
|
||||
validation_msg_md = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
if not READY_TO_TEST:
|
||||
self.setDisabled(True)
|
||||
self.tester = None
|
||||
else:
|
||||
self.tester = DeviceTester({})
|
||||
self.tester.signals.device_validated.connect(self._on_device_validated)
|
||||
QtCore.QThreadPool.globalInstance().start(self.tester)
|
||||
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
|
||||
# TODO Consider using the thread pool from BECConnector instead of fetching the global instance!
|
||||
self._thread_pool = QtCore.QThreadPool.globalInstance()
|
||||
|
||||
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._main_layout.setSpacing(0)
|
||||
|
||||
# We add a splitter between the list and the text box
|
||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
|
||||
self._main_layout.addWidget(self.splitter)
|
||||
|
||||
self._setup_list_ui()
|
||||
|
||||
def _setup_list_ui(self):
|
||||
"""Setup the list UI."""
|
||||
self._list_widget = QtWidgets.QListWidget(self)
|
||||
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
self.splitter.addWidget(self._list_widget)
|
||||
# Connect signals
|
||||
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
@SafeSlot(list, bool, bool)
|
||||
def change_device_configs(
|
||||
self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False
|
||||
) -> None:
|
||||
"""Receive an update with device configs.
|
||||
|
||||
Args:
|
||||
device_configs (list[dict[str, Any]]): The updated device configurations.
|
||||
"""
|
||||
for cfg in device_configs:
|
||||
name = cfg.get("name", "<not found>")
|
||||
if added:
|
||||
if name in self._device_list_items:
|
||||
continue
|
||||
if self.tester:
|
||||
self._add_device(name, cfg)
|
||||
self.tester.submit([(name, cfg, connect)])
|
||||
continue
|
||||
if name not in self._device_list_items:
|
||||
continue
|
||||
self._remove_list_item(name)
|
||||
|
||||
def _add_device(self, name, cfg):
|
||||
item = QtWidgets.QListWidgetItem(self._list_widget)
|
||||
widget = ValidationListItem(device_name=name, device_config=cfg)
|
||||
|
||||
# wrap it in a QListWidgetItem
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self._list_widget.addItem(item)
|
||||
self._list_widget.setItemWidget(item, widget)
|
||||
self._device_list_items[name] = item
|
||||
|
||||
def _remove_list_item(self, device_name: str):
|
||||
"""Remove a device from the list."""
|
||||
# Get the list item
|
||||
item = self._device_list_items.pop(device_name)
|
||||
|
||||
# Retrieve the custom widget attached to the item
|
||||
widget = self._list_widget.itemWidget(item)
|
||||
if widget is not None:
|
||||
widget.deleteLater() # clean up custom widget
|
||||
|
||||
# Remove the item from the QListWidget
|
||||
row = self._list_widget.row(item)
|
||||
self._list_widget.takeItem(row)
|
||||
|
||||
@SafeSlot(str, bool, str)
|
||||
def _on_device_validated(self, device_name: str, success: bool, message: str):
|
||||
"""Handle the device validation result.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
success (bool): Whether the validation was successful.
|
||||
message (str): The validation message.
|
||||
"""
|
||||
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
|
||||
item = self._device_list_items.get(device_name, None)
|
||||
if not item:
|
||||
logger.error(f"Device {device_name} not found in the list.")
|
||||
return
|
||||
if success:
|
||||
self._remove_list_item(device_name=device_name)
|
||||
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
|
||||
else:
|
||||
widget: ValidationListItem = self._list_widget.itemWidget(item)
|
||||
widget.on_validation_failed(message)
|
||||
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
|
||||
|
||||
def _on_current_item_changed(
|
||||
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
|
||||
):
|
||||
"""Handle the current item change in the list widget.
|
||||
|
||||
Args:
|
||||
current (QListWidgetItem): The currently selected item.
|
||||
previous (QListWidgetItem): The previously selected item.
|
||||
"""
|
||||
widget: ValidationListItem = self._list_widget.itemWidget(current)
|
||||
if widget:
|
||||
try:
|
||||
formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg)
|
||||
self.validation_msg_md.emit(formatted_md)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"##Error formatting validation message for device {widget.device_name}:\n{e}"
|
||||
)
|
||||
self.validation_msg_md.emit(widget.validation_msg)
|
||||
else:
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
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...":
|
||||
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,
|
||||
)
|
||||
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```"
|
||||
|
||||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
def validation_running(self):
|
||||
return self._device_list_items != {}
|
||||
|
||||
@SafeSlot()
|
||||
def clear_list(self):
|
||||
"""Clear the device list."""
|
||||
self._thread_pool.clear()
|
||||
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
|
||||
logger.error("Failed to wait for threads to finish. Removing items from the list.")
|
||||
self._device_list_items.clear()
|
||||
self._list_widget.clear()
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the list."""
|
||||
item = self._device_list_items.pop(device_name, None)
|
||||
if item:
|
||||
self._list_widget.removeItemWidget(item)
|
||||
|
||||
def cleanup(self):
|
||||
if self.tester:
|
||||
self.tester.shutdown_event.set()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
wid = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(wid)
|
||||
wid.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager_ophyd_test = DMOphydTest()
|
||||
try:
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
import os
|
||||
|
||||
import bec_lib
|
||||
|
||||
config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
|
||||
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
|
||||
device_manager_ophyd_test.change_device_configs(config, True, True)
|
||||
layout.addWidget(device_manager_ophyd_test)
|
||||
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
|
||||
device_manager_ophyd_test.resize(800, 600)
|
||||
text_box = QtWidgets.QTextEdit()
|
||||
text_box.setReadOnly(True)
|
||||
layout.addWidget(text_box)
|
||||
device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown)
|
||||
wid.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -20,7 +20,7 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
@@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
Widget to submit new scans to the queue.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["remove", "screenshot"]
|
||||
USER_ACCESS = ["attach", "detach", "screenshot"]
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
ARG_BOX_POSITION: int = 2
|
||||
@@ -136,13 +136,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.button_layout = QHBoxLayout(self.scan_control_group)
|
||||
self.button_run_scan = QPushButton("Start", self.scan_control_group)
|
||||
self.button_run_scan.setStyleSheet(
|
||||
f"background-color: {palette.success.name()}; color: white"
|
||||
)
|
||||
self.button_run_scan.setProperty("variant", "success")
|
||||
self.button_stop_scan = StopButton(parent=self.scan_control_group)
|
||||
self.button_stop_scan.setStyleSheet(
|
||||
f"background-color: {palette.emergency.name()}; color: white"
|
||||
)
|
||||
self.button_layout.addWidget(self.button_run_scan)
|
||||
self.button_layout.addWidget(self.button_stop_scan)
|
||||
self.layout.addWidget(self.scan_control_group)
|
||||
@@ -472,6 +467,8 @@ 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):
|
||||
@@ -524,7 +521,6 @@ 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):
|
||||
@@ -547,12 +543,10 @@ class ScanControl(BECWidget, QWidget):
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
scan_control = ScanControl()
|
||||
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
window = scan_control
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -197,12 +198,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), convert_to_pixmap=False)
|
||||
material_icon(icon_name="add", size=(15, 15), icon_type=QIcon)
|
||||
)
|
||||
# Remove bundle button
|
||||
self.button_remove_bundle = QPushButton(self)
|
||||
self.button_remove_bundle.setIcon(
|
||||
material_icon(icon_name="remove", size=(15, 15), convert_to_pixmap=False)
|
||||
material_icon(icon_name="remove", size=(15, 15), icon_type=QIcon)
|
||||
)
|
||||
hbox_layout.addWidget(self.button_add_bundle)
|
||||
hbox_layout.addWidget(self.button_remove_bundle)
|
||||
|
||||
@@ -175,10 +175,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -249,10 +249,10 @@ class DictBackedTable(QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
|
||||
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
|
||||
@@ -9,10 +10,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
||||
def __init__(self, inprocess: bool = False):
|
||||
super().__init__()
|
||||
|
||||
self.inprocess = None
|
||||
self.client = None
|
||||
self.inprocess = inprocess
|
||||
self.ipyclient = None
|
||||
|
||||
self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess)
|
||||
self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess)
|
||||
self.set_default_style("linux")
|
||||
self._init_bec()
|
||||
|
||||
@@ -35,14 +36,13 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
||||
self._init_bec_kernel()
|
||||
|
||||
def _init_bec_inprocess(self):
|
||||
self.client = BECIPythonClient()
|
||||
self.client.start()
|
||||
|
||||
self.ipyclient = BECIPythonClient()
|
||||
self.ipyclient.start()
|
||||
self.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"bec": self.client,
|
||||
"dev": self.client.device_manager.devices,
|
||||
"scans": self.client.scans,
|
||||
"bec": self.ipyclient,
|
||||
"dev": self.ipyclient.device_manager.devices,
|
||||
"scans": self.ipyclient.scans,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,20 +57,47 @@ 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 = QMainWindow()
|
||||
win.setCentralWidget(BECJupyterConsole(True))
|
||||
win = JupyterConsoleWindow(inprocess=True)
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
469
bec_widgets/widgets/editors/monaco/monaco_dock.py
Normal file
469
bec_widgets/widgets/editors/monaco/monaco_dock.py
Normal file
@@ -0,0 +1,469 @@
|
||||
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,11 +1,24 @@
|
||||
from typing import Literal
|
||||
from __future__ import annotations
|
||||
|
||||
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, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QDialog, 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):
|
||||
@@ -14,6 +27,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
|
||||
text_changed = Signal(str)
|
||||
save_enabled = Signal(bool)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
@@ -21,6 +35,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"get_text",
|
||||
"insert_text",
|
||||
"delete_line",
|
||||
"open_file",
|
||||
"set_language",
|
||||
"get_language",
|
||||
"set_theme",
|
||||
@@ -32,9 +47,14 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"set_vim_mode_enabled",
|
||||
"set_lsp_header",
|
||||
"get_lsp_header",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
def __init__(
|
||||
self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
@@ -44,7 +64,30 @@ 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:
|
||||
"""
|
||||
@@ -58,14 +101,19 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
editor_theme = "vs" if theme == "light" else "vs-dark"
|
||||
self.set_theme(editor_theme)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> 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.editor.set_text(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)
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
@@ -73,6 +121,32 @@ 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.
|
||||
@@ -93,6 +167,32 @@ 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,
|
||||
@@ -210,6 +310,46 @@ 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([])
|
||||
@@ -231,7 +371,7 @@ if TYPE_CHECKING:
|
||||
scans: Scans
|
||||
|
||||
#######################################
|
||||
########## User Script #####################
|
||||
########## User Script ################
|
||||
#######################################
|
||||
|
||||
# This is a comment
|
||||
|
||||
145
bec_widgets/widgets/editors/monaco/scan_control_dialog.py
Normal file
145
bec_widgets/widgets/editors/monaco/scan_control_dialog.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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_())
|
||||
@@ -97,7 +97,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
class ExampleSchema1(BasicScanMetadata):
|
||||
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
||||
@@ -141,7 +141,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
layout.addWidget(selection)
|
||||
layout.addWidget(scan_metadata)
|
||||
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
window = w
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -172,9 +172,17 @@ class WebConsole(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id=None,
|
||||
startup_cmd: str | None = "bec --nogui",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._startup_cmd = "bec --nogui"
|
||||
self._startup_cmd = startup_cmd
|
||||
self._is_initialized = False
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
|
||||
@@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "travel_explore"
|
||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
||||
USER_ACCESS = [
|
||||
"set_url",
|
||||
"get_url",
|
||||
"reload",
|
||||
"back",
|
||||
"forward",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
||||
|
||||
@@ -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
|
||||
from qtpy.QtGui import QBrush, QColor, QPainter, QPen, QPixmap
|
||||
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", convert_to_pixmap=True, filled=True))
|
||||
p.drawPixmap(r, material_icon("experiment", icon_type=QPixmap, 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),
|
||||
convert_to_pixmap=True,
|
||||
icon_type=QPixmap,
|
||||
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", convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="add", icon_type=QIcon)
|
||||
case GameStatus.PLAYING:
|
||||
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="smart_toy", icon_type=QIcon)
|
||||
case GameStatus.FAILED:
|
||||
icon = material_icon(icon_name="error", convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="error", icon_type=QIcon)
|
||||
case GameStatus.SUCCESS:
|
||||
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
|
||||
icon = material_icon(icon_name="celebration", icon_type=QIcon)
|
||||
self.reset_button.setIcon(icon)
|
||||
|
||||
def update_timer(self):
|
||||
@@ -407,10 +407,10 @@ class Minesweeper(BECWidget, QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = Minesweeper()
|
||||
widget.show()
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ class Heatmap(ImageBase):
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
|
||||
@@ -91,6 +91,8 @@ class Image(ImageBase):
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Literal
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtGui import QColor, QIcon
|
||||
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), convert_to_pixmap=False))
|
||||
self.setIcon(material_icon(icon, size=(20, 20), icon_type=QIcon))
|
||||
|
||||
|
||||
class ROIPropertyTree(BECWidget, QWidget):
|
||||
@@ -209,11 +209,11 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
if on:
|
||||
# switched to expanded state
|
||||
self.tree.expandAll()
|
||||
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
|
||||
new_icon = material_icon("unfold_less", size=(20, 20), icon_type=QIcon)
|
||||
else:
|
||||
# collapsed state
|
||||
self.tree.collapseAll()
|
||||
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
|
||||
new_icon = material_icon("unfold_more", size=(20, 20), icon_type=QIcon)
|
||||
self.expand_toggle.action.setIcon(new_icon)
|
||||
|
||||
self.expand_toggle.action.toggled.connect(_exp_toggled)
|
||||
@@ -231,7 +231,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), convert_to_pixmap=False
|
||||
"lock" if checked else "lock_open_right", size=(20, 20), icon_type=QIcon
|
||||
)
|
||||
self.lock_all_action.action.setIcon(new_icon)
|
||||
|
||||
@@ -402,11 +402,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
# delete button
|
||||
del_btn = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.DELETE_BUTTON_COLOR,
|
||||
"delete", size=(20, 20), icon_type=QIcon, filled=False, color=self.DELETE_BUTTON_COLOR
|
||||
)
|
||||
del_btn.setIcon(delete_icon)
|
||||
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
|
||||
|
||||
@@ -11,7 +11,7 @@ from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
@@ -128,6 +128,8 @@ class MotorMap(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# motor_map specific
|
||||
"color",
|
||||
@@ -828,7 +830,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -96,6 +96,8 @@ class MultiWaveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
|
||||
@@ -135,7 +135,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._init_ui()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
self._update_theme()
|
||||
self._update_theme(None)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
self.round_plot_widget.apply_theme(theme)
|
||||
@@ -143,6 +143,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
def _init_ui(self):
|
||||
self.layout.addWidget(self.layout_manager)
|
||||
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
|
||||
self.round_plot_widget.setProperty("variant", "plot_background")
|
||||
self.round_plot_widget.setProperty("frameless", True)
|
||||
|
||||
self.layout_manager.add_widget(self.round_plot_widget)
|
||||
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
|
||||
|
||||
@@ -10,7 +10,6 @@ from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
@@ -84,6 +83,8 @@ class ScatterWaveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
@@ -544,8 +545,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -5,7 +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 QValidator
|
||||
from qtpy.QtGui import QIcon, QValidator
|
||||
|
||||
|
||||
class ScanIndexValidator(QValidator):
|
||||
@@ -34,6 +34,7 @@ class ScanIndexValidator(QValidator):
|
||||
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
@@ -97,6 +98,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
# A top-level device row.
|
||||
super().__init__(tree)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tree = tree
|
||||
self.parent_item = parent_item
|
||||
self.curve_tree = tree.parent() # The CurveTree widget
|
||||
@@ -182,11 +184,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
# Delete button
|
||||
self.delete_button = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.DELETE_BUTTON_COLOR,
|
||||
"delete", size=(20, 20), icon_type=QIcon, filled=False, color=self.DELETE_BUTTON_COLOR
|
||||
)
|
||||
self.delete_button.setIcon(delete_icon)
|
||||
self.delete_button.clicked.connect(lambda: self.remove_self())
|
||||
@@ -194,7 +192,16 @@ class CurveRow(QTreeWidgetItem):
|
||||
|
||||
# If device row, add "Add DAP" button
|
||||
if self.source in ("device", "history"):
|
||||
self.add_dap_button = QPushButton("DAP")
|
||||
self.add_dap_button = QToolButton()
|
||||
analysis_icon = material_icon(
|
||||
"monitoring",
|
||||
size=(20, 20),
|
||||
icon_type=QIcon,
|
||||
filled=False,
|
||||
color=self.app.theme.colors["FG"].toTuple(),
|
||||
)
|
||||
self.add_dap_button.setIcon(analysis_icon)
|
||||
self.add_dap_button.setToolTip("Add DAP")
|
||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||
actions_layout.addWidget(self.add_dap_button)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
from bec_widgets.utils.colors import Colors, set_theme
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
@@ -67,6 +67,10 @@ class Waveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
# BECWidget Base Class
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# General PlotBase Settings
|
||||
"_config_dict",
|
||||
"enable_toolbar",
|
||||
@@ -109,7 +113,6 @@ class Waveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -2390,7 +2393,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -92,6 +92,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
"set_diameter",
|
||||
"reset_diameter",
|
||||
"enable_auto_updates",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user