1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-13 20:20:55 +02:00

Compare commits

..

1 Commits

8 changed files with 743 additions and 245 deletions

View File

@@ -1,98 +0,0 @@
from typing import List
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
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 AutoHideOverlay(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **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)
# IMPORTANT, if you decide to use autohide, you must set these flags before creating ANY CDockManager, it has to be put into inity, let me know and I can enforce it
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
CDockManager.setAutoHideConfigFlag(
CDockManager.DockAreaHasAutoHideButton, False
) # to not have everywhere these buttons
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.auto_hide_controls = QPushButton("Auto Hide Controls", self.left_widget)
self.auto_hide_controls.setCheckable(True)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.auto_hide_controls)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
# Connect signals
self.auto_hide_controls.toggled.connect(self.toggle_auto_hide)
def toggle_auto_hide(self, checked: bool):
# start as collapsed, implement better logic
# self.autohide_container.collapseView(checked)
# or this if you just want toggle
self.autohide_container.toggleCollapseState()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHideOverlay()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
sys.exit(app.exec())

View File

@@ -1,123 +0,0 @@
from typing import Optional
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
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 AutoHidePush(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **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)
# CDockManager.setConfigFlag(CDockManager.FocusHighlighting, True)
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
# CDockManager.setAutoHideConfigFlag(CDockManager.AutoHideShowOnMouseOver, True)
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.push_mode_btn = QPushButton(self.left_widget)
self.push_mode_btn.setCheckable(True)
self.push_mode_btn.setText("Pin (show and push)")
self.push_mode_btn.setChecked(False)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.push_mode_btn)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
self._last_side = QtAds.SideBarRight
# Ensure auto-hide starts collapsed and button text is correct
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
# Connect signals
self.push_mode_btn.toggled.connect(self.toggle_pin_mode)
def toggle_pin_mode(self, pinned: bool):
# pinned=True -> convert auto-hide overlay into a normal dock (push layout) and show it
if pinned:
if self.autohide_container is not None:
# Remember the current side (edge) to restore when unpinning
if hasattr(self.autohide_container, "sideBarLocation"):
self._last_side = self.autohide_container.sideBarLocation()
# Move contents back into the dock container (this deletes the auto-hide container)
self.autohide_container.moveContentsToParent()
self.autohide_container = None
# Ensure the dock is visible when pinned
self.list_dock.show()
self.push_mode_btn.setText("Unpin (send to sidebar)")
else:
# Convert the pinned dock back into an auto-hide overlay at the last used side and collapse it
side = getattr(self, "_last_side", QtAds.SideBarRight)
container = self.dock_manager.addAutoHideDockWidget(side, self.list_dock)
# Preserve a sensible size from the current dock widget geometry
if side in (QtAds.SideBarLeft, QtAds.SideBarRight):
target_size = max(200, min(self.list_dock.width(), 600))
else:
target_size = max(200, min(self.list_dock.height(), 600))
container.setSize(int(target_size))
self.autohide_container = container
# Collapse so it disappears to the side tab
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHidePush()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
sys.exit(app.exec())

View File

@@ -0,0 +1,206 @@
from typing import List
import PySide6QtAds as QtAds
import yaml
from bec_qthemes import material_icon
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QPushButton,
QSizePolicy,
QSplitter,
QStackedLayout,
QTreeWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import (
DeviceManagerOphydTest,
)
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
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.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 DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **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 = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.explorer = IDEExplorer(self) # TODO will be replaced by explorer widget
self.device_table_view = DeviceTableView(self)
# Placeholder
self.dm_config_view = DMConfigView(self)
# Placeholder for ophyd test
WebConsole.startup_cmd = "ipython"
self.ophyd_test = DeviceManagerOphydTest(self)
self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self)
self.ophyd_test_dock.setWidget(self.ophyd_test)
# Create the dock widgets
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
self.explorer_dock.setWidget(self.explorer)
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Table will be central widget
self.dock_manager.setCentralWidget(self.device_table_view_dock)
self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock)
monaco_yaml_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock
)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area
)
for dock in self.dock_manager.dockWidgets():
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
# Fetch all dock areas of the dock widgets (on our case always one dock area)
for dock in self.dock_manager.dockWidgets():
area = dock.dockAreaWidget()
area.titleBar().setVisible(False)
# Apply stretch after the layout is done
self.set_default_view([2, 5, 3], [5, 5])
# Connect slots
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager_view = DeviceManagerView()
config = device_manager_view.client.device_manager._get_redis_device_config()
device_manager_view.device_table_view.set_device_config(config)
device_manager_view.show()
device_manager_view.setWindowTitle("Device Manager View")
device_manager_view.resize(1200, 800)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,73 @@
"""Top Level wrapper for device_manager widget"""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
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.setStyleSheet(
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
self._overlay_widget.setVisible(True)
@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.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager = DeviceManagerWidget()
# config = device_manager.client.device_manager._get_redis_device_config()
# device_manager.device_table_view.set_device_config(config)
device_manager.show()
device_manager.setWindowTitle("Device Manager View")
device_manager.resize(1600, 1200)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -23,20 +23,14 @@ FUZZY_SEARCH_THRESHOLD = 80
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
@staticmethod
def dict_to_str(d: dict) -> str:
"""Convert a dictionary to a formatted string."""
return json.dumps(d, indent=4)
def helpEvent(self, event, view, option, index):
"""Override to show tooltip when hovering."""
if event.type() != QtCore.QEvent.ToolTip:
return super().helpEvent(event, view, option, index)
model: DeviceFilterProxyModel = index.model()
model_index = model.mapToSource(index)
row_dict = model.sourceModel().row_data(model_index)
row_dict.pop("description", None)
QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view)
row_dict = model.sourceModel().get_row_data(model_index)
QtWidgets.QToolTip.showText(event.globalPos(), row_dict["description"], view)
return True
@@ -47,10 +41,10 @@ class CenterCheckBoxDelegate(DictToolTipDelegate):
super().__init__(parent)
colors = get_accent_colors()
self._icon_checked = material_icon(
"check_box", size=QtCore.QSize(16, 16), color=colors.default
"check_box", size=QtCore.QSize(16, 16), color=colors.default, filled=True
)
self._icon_unchecked = material_icon(
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default, filled=True
)
def apply_theme(self, theme: str | None = None):
@@ -128,10 +122,9 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
"name",
"deviceClass",
"readoutPriority",
"deviceTags",
"enabled",
"readOnly",
"deviceTags",
"description",
]
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
@@ -150,7 +143,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
return self.headers[section]
return None
def row_data(self, index: QtCore.QModelIndex) -> dict:
def get_row_data(self, index: QtCore.QModelIndex) -> dict:
"""Return the row data for the given index."""
if not index.isValid():
return {}
@@ -169,6 +162,8 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
return bool(value)
if key == "deviceTags":
return ", ".join(str(tag) for tag in value) if value else ""
if key == "deviceClass":
return str(value).split(".")[-1]
return str(value) if value is not None else ""
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
@@ -436,6 +431,8 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
class DeviceTableView(BECWidget, QtWidgets.QWidget):
"""Device Table View for the device manager."""
selected_device = QtCore.Signal(dict)
RPC = False
PLUGIN = False
devices_removed = QtCore.Signal(list)
@@ -508,10 +505,9 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority
self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly
self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags
self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description
self.table.setItemDelegateForColumn(3, self.wrap_delegate) # deviceTags
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # readOnly
# Column resize policies
# TODO maybe we need here a flexible header options as deviceClass
@@ -520,13 +516,12 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly
# TODO maybe better stretch...
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description
self.table.setColumnWidth(3, 82)
self.table.setColumnWidth(4, 82)
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) # deviceTags
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # enabled
header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # readOnly
self.table.setColumnWidth(3, 70)
self.table.setColumnWidth(4, 70)
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
@@ -538,6 +533,8 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
# Selection behavior
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
# Connect to selection model to get selection changes
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
self.table.horizontalHeader().setHighlightSections(False)
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
@@ -566,6 +563,45 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
height = delegate.sizeHint(option, index).height()
self.table.setRowHeight(row, height)
@SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection)
def _on_selection_changed(
self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection
) -> None:
"""
Handle selection changes in the device table.
Args:
selected (QtCore.QItemSelection): The selected items.
deselected (QtCore.QItemSelection): The deselected items.
"""
# TODO also hook up logic if a config update is propagated from somewhere!
# selected_indexes = selected.indexes()
selected_indexes = self.table.selectionModel().selectedIndexes()
if not selected_indexes:
return
source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes]
source_rows = {idx.row() for idx in source_indexes}
# Ignore if multiple are selected
if len(source_rows) > 1:
self.selected_device.emit({})
return
# Get the single row
(row,) = source_rows
source_index = self.model.index(row, 0) # pick column 0 or whichever
device = self.model.get_row_data(source_index)
self.selected_device.emit(device)
@SafeSlot(QtCore.QModelIndex)
def _on_row_selected(self, index: QtCore.QModelIndex):
"""Handle row selection in the device table."""
if not index.isValid():
return
source_index = self.proxy.mapToSource(index)
device = self.model.get_device_at_index(source_index)
self.selected_device.emit(device)
######################################
##### Ext. Slot API #################
######################################

View File

@@ -0,0 +1,71 @@
"""Module with a config view for the device manager."""
from __future__ import annotations
import yaml
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
class DMConfigView(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.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)
def _customize_overlay(self):
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setStyleSheet(
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
@SafeSlot(dict)
def on_select_config(self, device: dict):
"""Handle selection of a device from the device table."""
if not device:
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
else:
text = yaml.dump(device, default_flow_style=False)
self.stacked_layout.setCurrentWidget(self.monaco_editor)
self.monaco_editor.set_readonly(False) # Enable editing
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)
config_view = DMConfigView()
config_view.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,333 @@
"""Module to run a static test for the current config and see if it is valid."""
from __future__ import annotations
import enum
import bec_lib
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, 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 SafeProperty, SafeSlot
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
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
class ValidationStatus(int, enum.Enum):
"""Validation status for device configurations."""
UNKNOWN = 0 # colors.default
ERROR = 1 # colors.emergency
VALID = 2 # colors.highlight
CANT_CONNECT = 3 # colors.warning
CONNECTED = 4 # colors.success
class DeviceValidationListItem(QtWidgets.QWidget):
"""Custom list item widget showing device name and validation status."""
status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus
# Signal emitted when device was validated with name, success, msg
device_validated = QtCore.Signal(str, str)
def __init__(
self,
device_config: dict[str, dict],
status: ValidationStatus,
status_icons: dict[ValidationStatus, QtGui.QPixmap],
validate_icon: QtGui.QPixmap,
parent=None,
static_device_test=None,
):
super().__init__(parent)
if len(device_config.keys()) > 1:
logger.warning(
f"Multiple devices found for config: {list(device_config.keys())}, using first one"
)
self._static_device_test = static_device_test
self.device_name = list(device_config.keys())[0]
self.device_config = device_config
self.status: ValidationStatus = status
colors = get_accent_colors()
self._status_icon = status_icons
self._validate_icon = validate_icon
self._setup_ui()
self._update_status_indicator()
def _setup_ui(self):
"""Setup the UI for the list item."""
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
# Device name label
self.name_label = QtWidgets.QLabel(self.device_name)
self.name_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.name_label)
# Make sure status is on the right
layout.addStretch()
self.request_validation_button = QtWidgets.QPushButton("Validate")
self.request_validation_button.setIcon(self._validate_icon)
if self._static_device_test is None:
self.request_validation_button.setDisabled(True)
else:
self.request_validation_button.clicked.connect(self.on_request_validation)
# self.request_validation_button.setVisible(False) -> Hide it??
layout.addWidget(self.request_validation_button)
# Status indicator
self.status_indicator = QtWidgets.QLabel()
self._update_status_indicator()
layout.addWidget(self.status_indicator)
@SafeSlot()
def on_request_validation(self):
"""Handle validate button click."""
if self._static_device_test is None:
logger.warning("Static device test not available.")
return
self._static_device_test.config = self.device_config
# TODO logic if connect is allowed
ret = self._static_device_test.run_with_list_output(connect=False)[0]
if ret.success:
self.set_status(ValidationStatus.VALID)
else:
self.set_status(ValidationStatus.ERROR)
self.device_validated.emit(ret.name, ret.message)
def _update_status_indicator(self):
"""Update the status indicator color based on validation status."""
self.status_indicator.setPixmap(self._status_icon[self.status])
def set_status(self, status: ValidationStatus):
"""Update the validation status."""
self.status = status
self._update_status_indicator()
self.status_changed.emit(self.status)
def get_status(self) -> ValidationStatus:
"""Get the current validation status."""
return self.status
class DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget):
config_changed = QtCore.Signal(
dict, dict
) # Signal emitted when the device config changed, new_config, old_config
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
if not READY_TO_TEST:
self._set_disabled()
static_device_test = None
else:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
static_device_test = StaticDeviceTest(config_dict={})
self._static_device_test = static_device_test
self._device_config: dict[str, dict] = {}
self._main_layout = QtWidgets.QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(4)
# Setup icons
colors = get_accent_colors()
self._validate_icon = material_icon(
icon_name="play_arrow", color=colors.default, filled=True
)
self._status_icons = {
ValidationStatus.UNKNOWN: material_icon(
icon_name="circle", size=(12, 12), color=colors.default, filled=True
),
ValidationStatus.ERROR: material_icon(
icon_name="circle", size=(12, 12), color=colors.emergency, filled=True
),
ValidationStatus.VALID: material_icon(
icon_name="circle", size=(12, 12), color=colors.highlight, filled=True
),
ValidationStatus.CANT_CONNECT: material_icon(
icon_name="circle", size=(12, 12), color=colors.warning, filled=True
),
ValidationStatus.CONNECTED: material_icon(
icon_name="circle", size=(12, 12), color=colors.success, filled=True
),
}
self.setLayout(self._main_layout)
# splitter
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
self._main_layout.addWidget(self.splitter)
# Add custom list
self.setup_device_validation_list()
# Setup text box
self.setup_text_box()
# Connect signals
self.config_changed.connect(self.on_config_updated)
@SafeSlot(list)
def on_device_config_update(self, config: list[dict]):
old_cfg = self._device_config
self._device_config = self._compile_device_config_list(config)
self.config_changed.emit(self._device_config, old_cfg)
def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]:
return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config}
@SafeSlot(dict, dict)
def on_config_updated(self, new_config: dict, old_config: dict):
"""Handle config updates and refresh the validation list."""
# Find differences for potential re-validation
diffs = self._find_diffs(new_config, old_config)
# Check diff first
for diff in diffs:
if not diff:
continue
if len(diff) > 1:
logger.warning(f"Multiple devices found in diff: {diff}, using first one")
name = list(diff.keys())[0]
if name in self.client.device_manager.devices:
status = ValidationStatus.CONNECTED
else:
status = ValidationStatus.UNKNOWN
if self.get_device_status(diff) is None:
self.add_device(diff, status)
else:
self.update_device_status(diff, status)
def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]:
"""
Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary.
Args:
new_config: The first dictionary to compare.
old_config: The second dictionary to compare.
"""
diffs = []
keys = set(new_config.keys()) | set(old_config.keys())
for k in keys:
if k not in old_config: # New device
diffs.append({k: new_config[k]})
continue
if k not in new_config: # Removed device
diffs.append({k: old_config[k]})
continue
# Compare device config if exists in both
v1, v2 = old_config[k], new_config[k]
if isinstance(v1, dict) and isinstance(v2, dict):
if self._find_diffs(v2, v1): # recurse: something inside changed
diffs.append({k: new_config[k]})
elif v1 != v2:
diffs.append({k: new_config[k]})
return diffs
def setup_device_validation_list(self):
"""Setup the device validation list."""
# Create the custom validation list widget
self.validation_list = QtWidgets.QListWidget()
self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.splitter.addWidget(self.validation_list)
# self._main_layout.addWidget(self.validation_list)
def setup_text_box(self):
"""Setup the text box for device validation messages."""
self.validation_text_box = QtWidgets.QTextEdit()
self.validation_text_box.setReadOnly(True)
self.splitter.addWidget(self.validation_text_box)
# self._main_layout.addWidget(self.validation_text_box)
@SafeSlot(str, str)
def on_device_validated(self, device_name: str, message: str):
"""Handle device validation results."""
text = f"Device {device_name} was validated. Message: {message}"
self.validation_text_box.setText(text)
def _set_disabled(self) -> None:
"""Disable the full view"""
self.setDisabled(True)
def add_device(
self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN
):
"""Add a device to the validation list."""
# Create the custom widget
item_widget = DeviceValidationListItem(
device_config=device_config,
status=status,
status_icons=self._status_icons,
validate_icon=self._validate_icon,
static_device_test=self._static_device_test,
)
# Create a list widget item
list_item = QtWidgets.QListWidgetItem()
list_item.setSizeHint(item_widget.sizeHint())
# Add item to list and set custom widget
self.validation_list.addItem(list_item)
self.validation_list.setItemWidget(list_item, item_widget)
item_widget.device_validated.connect(self.on_device_validated)
def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus):
"""Update the validation status for a specific device."""
for i in range(self.validation_list.count()):
item = self.validation_list.item(i)
widget = self.validation_list.itemWidget(item)
if (
isinstance(widget, DeviceValidationListItem)
and widget.device_config == device_config
):
widget.set_status(status)
break
def clear_devices(self):
"""Clear all devices from the list."""
self.validation_list.clear()
def get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None:
"""Get the validation status for a specific device."""
for i in range(self.validation_list.count()):
item = self.validation_list.item(i)
widget = self.validation_list.itemWidget(item)
if (
isinstance(widget, DeviceValidationListItem)
and widget.device_config == device_config
):
return widget.get_status()
return None
if __name__ == "__main__":
import sys
# pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager_ophyd_test = DeviceManagerOphydTest()
cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config()
cfg.append({"name": "Wrong_Device", "type": "test"})
device_manager_ophyd_test.on_device_config_update(cfg)
device_manager_ophyd_test.show()
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
device_manager_ophyd_test.resize(800, 600)
sys.exit(app.exec_())