1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

9 Commits

Author SHA1 Message Date
c1c0dff888 tmp: theme comparison widget galery 2025-08-25 10:05:30 +02:00
semantic-release
f1c3d77a45 2.38.1
Automatically generated by python-semantic-release
2025-08-22 10:06:47 +00:00
ad7cdc60dd fix: move thefuzz dependency to prod 2025-08-22 12:06:01 +02:00
semantic-release
ba047fd776 2.38.0
Automatically generated by python-semantic-release
2025-08-19 15:12:14 +00:00
6e05157abb feat(device_manager): DeviceManager view of config session 2025-08-19 17:11:24 +02:00
semantic-release
f4bc759e72 2.37.0
Automatically generated by python-semantic-release
2025-08-19 14:52:20 +00:00
1bec9bd9b2 feat: add explorer widget 2025-08-19 16:51:38 +02:00
semantic-release
8b013d5dce 2.36.0
Automatically generated by python-semantic-release
2025-08-18 10:45:14 +00:00
f2e5a85e61 feat(scan control): add support for literals 2025-08-18 12:44:29 +02:00
104 changed files with 1920 additions and 645 deletions

View File

@@ -1,6 +1,38 @@
# CHANGELOG
## v2.38.1 (2025-08-22)
### Bug Fixes
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features
- **device_manager**: Devicemanager view of config session
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
## v2.37.0 (2025-08-19)
### Features
- Add explorer widget
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
## v2.36.0 (2025-08-18)
### Features
- **scan control**: Add support for literals
([`f2e5a85`](https://github.com/bec-project/bec_widgets/commit/f2e5a85e616aa76d4b7ad3b3c76a24ba114ebdd1))
## v2.35.0 (2025-08-14)
### Build System

View File

@@ -0,0 +1,204 @@
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 bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
class CollapsibleSection(QWidget):
"""A widget that combines a header button with any content widget for collapsible sections
This widget contains a header button with a title and a content widget.
The content widget can be any QWidget. The header button can be expanded or collapsed.
The header also contains an "Add" button that is only visible when hovering over the section.
Signals:
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
onto another section for reordering.
Arguments are (source_title, target_title).
"""
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
self.setAcceptDrops(True)
self._expanded = True
# Setup layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
self.main_layout.setSpacing(0)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 4, 0)
header_layout.setSpacing(0)
# Create header button
self.header_button = QPushButton()
self.header_button.clicked.connect(self.toggle_expanded)
# Enable drag and drop for reordering
self.header_button.setAcceptDrops(True)
self.header_button.mousePressEvent = self._header_mouse_press_event
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
self.drag_start_position = None
# Add header to layout
header_layout.addWidget(self.header_button)
header_layout.addStretch()
self.header_add_button = QPushButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
self._update_expanded_state()
def set_widget(self, widget):
"""Set the content widget for this collapsible section"""
# Remove existing content widget if any
if self.content_widget and self.content_widget.parent() == self:
self.main_layout.removeWidget(self.content_widget)
self.content_widget.close()
self.content_widget.deleteLater()
self.content_widget = widget
if self.content_widget:
self.main_layout.addWidget(self.content_widget)
self._update_expanded_state()
def _update_appearance(self):
"""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)
self.header_button.setIcon(icon)
self.header_button.setText(self.title)
# Get theme colors
palette = get_theme_palette()
text_color = palette.text().color().name()
self.header_button.setStyleSheet(
f"""
QPushButton {{
font-weight: bold;
text-align: left;
margin: 0;
padding: 0px;
border: none;
background: transparent;
color: {text_color};
icon-size: 20px 20px;
}}
"""
)
def toggle_expanded(self):
"""Toggle the expanded state and update size policy"""
self.expanded = not self.expanded
self._update_expanded_state()
def _update_expanded_state(self):
"""Update the expanded state based on current state"""
self._update_appearance()
if self.expanded:
if self.content_widget:
self.content_widget.show()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
else:
if self.content_widget:
self.content_widget.hide()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@SafeProperty(bool)
def expanded(self) -> bool:
"""Get the expanded state"""
return self._expanded
@expanded.setter
def expanded(self, value: bool):
"""Set the expanded state programmatically"""
if not isinstance(value, bool):
raise ValueError("Expanded state must be a boolean")
if self._expanded == value:
return
self._expanded = value
self._update_appearance()
def connect_add_button(self, slot):
"""Connect a slot to the add button's clicked signal.
Args:
slot: The function to call when the add button is clicked.
"""
self.header_add_button.clicked.connect(slot)
def _header_mouse_press_event(self, event):
"""Handle mouse press on header for drag start"""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.pos()
QPushButton.mousePressEvent(self.header_button, event)
def _header_mouse_move_event(self, event):
"""Handle mouse move to start drag operation"""
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
# Check if we've moved far enough to start a drag
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
self._start_drag()
QPushButton.mouseMoveEvent(self.header_button, event)
def _start_drag(self):
"""Start the drag operation with a properly aligned widget pixmap"""
drag = QDrag(self.header_button)
mime_data = QMimeData()
mime_data.setText(f"section:{self.title}")
drag.setMimeData(mime_data)
# Grab a pixmap of the widget
widget_pixmap = self.header_button.grab()
drag.setPixmap(widget_pixmap)
# Set the hotspot to where the mouse was pressed on the widget
drag.setHotSpot(self.drag_start_position)
drag.exec_(Qt.MoveAction)
def _header_drag_enter_event(self, event):
"""Handle drag enter on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
event.acceptProposedAction()
else:
event.ignore()
def _header_drop_event(self, event):
"""Handle drop on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
source_title = event.mimeData().text().replace("section:", "")
if source_title != self.title:
# Emit signal to parent to handle reordering
self.section_reorder_requested.emit(source_title, self.title)
event.acceptProposedAction()
else:
event.ignore()

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
class Explorer(BECWidget, QWidget):
"""
A widget that combines multiple collapsible sections for an explorer-like interface.
Each section can be expanded or collapsed, and sections can be reordered. The explorer
can contain also sub-explorers for nested structures.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# Splitter for sections
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.main_layout.addWidget(self.splitter)
# Spacer for when all sections are collapsed
self.expander = QSpacerItem(0, 0)
self.main_layout.addItem(self.expander)
# Registry of sections
self.sections: list[CollapsibleSection] = []
# Setup splitter styling
self._setup_splitter_styling()
def add_section(self, section: CollapsibleSection) -> None:
"""
Add a collapsible section to the explorer
Args:
section (CollapsibleSection): The section to add
"""
if not isinstance(section, CollapsibleSection):
raise TypeError("section must be an instance of CollapsibleSection")
if section in self.sections:
return
self.sections.append(section)
self.splitter.addWidget(section)
# Connect the section's toggle to update spacer
section.header_button.clicked.connect(self._update_spacer)
# Connect section reordering if supported
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.connect(self._handle_section_reorder)
self._update_spacer()
def remove_section(self, section: CollapsibleSection) -> None:
"""
Remove a collapsible section from the explorer
Args:
section (CollapsibleSection): The section to remove
"""
if section not in self.sections:
return
self.sections.remove(section)
section.deleteLater()
section.close()
# Disconnect signals
try:
section.header_button.clicked.disconnect(self._update_spacer)
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.disconnect(self._handle_section_reorder)
except RuntimeError:
# Signals already disconnected
pass
self._update_spacer()
def get_section(self, title: str) -> CollapsibleSection | None:
"""Get a section by its title"""
for section in self.sections:
if section.title == title:
return section
return None
def _setup_splitter_styling(self) -> None:
"""Setup the splitter styling with theme colors"""
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
QSplitter::handle {{
height: 0.1px;
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
}}
"""
)
def _update_spacer(self) -> None:
"""Update the spacer size based on section states"""
any_expanded = any(section.expanded for section in self.sections)
if any_expanded:
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else:
self.expander.changeSize(
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
)
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
"""Handle reordering of sections"""
if source_title == target_title:
return
source_section = self.get_section(source_title)
target_section = self.get_section(target_title)
if not source_section or not target_section:
return
# Get current indices
source_index = self.splitter.indexOf(source_section)
target_index = self.splitter.indexOf(target_section)
if source_index == -1 or target_index == -1:
return
# Insert at target position
self.splitter.insertWidget(target_index, source_section)
# Update sections
self.sections.remove(source_section)
self.sections.insert(target_index, source_section)
if __name__ == "__main__":
import os
from qtpy.QtWidgets import QApplication, QLabel
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
app = QApplication([])
explorer = Explorer()
section = CollapsibleSection(title="SCRIPTS", indentation=0)
script_explorer = Explorer()
script_widget = ScriptTreeWidget()
local_scripts_section = CollapsibleSection(title="Local")
local_scripts_section.set_widget(script_widget)
script_widget.set_directory(os.path.abspath("./"))
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
explorer.add_section(section)
shared_script_section = CollapsibleSection(title="Shared")
shared_script_widget = ScriptTreeWidget()
shared_script_widget.set_directory(os.path.abspath("./"))
shared_script_section.set_widget(shared_script_widget)
script_explorer.add_section(shared_script_section)
macros_section = CollapsibleSection(title="MACROS", indentation=0)
macros_section.set_widget(QLabel("Macros will be implemented later"))
explorer.add_section(macros_section)
explorer.show()
app.exec()

View File

@@ -0,0 +1,387 @@
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 bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""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 add_file_action(self, action: QAction) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
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
if not isinstance(model, QSortFilterProxyModel):
return super().editorEvent(event, model, option, index)
source_index = model.mapToSource(index)
source_model = model.sourceModel()
# Early return if not a file system model
if not isinstance(source_model, QFileSystemModel):
return super().editorEvent(event, model, option, index)
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
class ScriptTreeWidget(QWidget):
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
file_selected = Signal(str) # Script file path selected
file_open_requested = Signal(str) # File open button clicked
file_renamed = Signal(str, str) # Old path, new 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)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)
# Create and set custom delegate
self.delegate = FileItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for files
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
action.action.triggered.connect(self._on_file_open_requested)
self.delegate.add_file_action(action.action)
# Remove unnecessary columns
self.tree.setColumnHidden(1, True) # Hide size column
self.tree.setColumnHidden(2, True) # Hide type column
self.tree.setColumnHidden(3, True) # Hide date modified column
# Apply BEC styling
self._apply_styling()
# Script 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)
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
}}
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 scripts directory"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
self.tree.expandAll()
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
path_obj = Path(file_path)
# Only emit signal for Python files
if path_obj.suffix.lower() == ".py":
logger.info(f"Script selected: {file_path}")
self.file_selected.emit(file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
# Emit signal to open the file
logger.info(f"File open requested via double-click: {file_path}")
self.file_open_requested.emit(file_path)
def _on_file_open_requested(self):
"""Handle file open action triggered"""
logger.info("File open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_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.model.setRootPath("") # Reset
root_index = self.model.setRootPath(self.directory)
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()

View File

@@ -1,3 +0,0 @@
from .available_device_resources import AvailableDeviceResources
__all__ = ["AvailableDeviceResources"]

View File

@@ -1,61 +0,0 @@
from random import randint
from typing import Iterable
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QListWidgetItem, QWidget
from bec_widgets.utils.bec_widget import BECWidget
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.available_device_resources.device_tag_group import (
DeviceTagGroup,
)
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self._backend = get_backend()
self._items: dict[str, tuple[QListWidgetItem, DeviceTagGroup]] = {}
self.refresh_full_list()
def refresh_full_list(self):
self.tag_groups_list.clear()
self._items = {}
for tag_group, devices in self._backend.tag_groups.items():
item = QListWidgetItem(self.tag_groups_list)
tag_group_widget = DeviceTagGroup(self.tag_groups_list, tag_group, devices)
self.tag_groups_list.setItemWidget(item, tag_group_widget)
self.tag_groups_list.addItem(item)
self._items[tag_group] = (item, tag_group_widget)
item.setSizeHint(QSize(tag_group_widget.width(), tag_group_widget.height()))
def set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
for _, tag_group in self._items.values():
for device in devices:
tag_group.set_item_state(hash(device), included)
def resizeEvent(self, event):
super().resizeEvent(event)
for list_item, tag_group_widget in self._items.values():
list_item.setSizeHint(tag_group_widget.sizeHint())
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())

View File

@@ -1,27 +0,0 @@
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import QAbstractItemView, QListView, QListWidget, QVBoxLayout
class Ui_availableDeviceResources(object):
def setupUi(self, availableDeviceResources):
if not availableDeviceResources.objectName():
availableDeviceResources.setObjectName("availableDeviceResources")
self.verticalLayout = QVBoxLayout(availableDeviceResources)
self.verticalLayout.setObjectName("verticalLayout")
self.tag_groups_list = QListWidget(availableDeviceResources)
self.tag_groups_list.setObjectName("tag_groups_list")
self.tag_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.tag_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.tag_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.tag_groups_list.setMovement(QListView.Movement.Static)
self.tag_groups_list.setSpacing(2)
self.tag_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
self.tag_groups_list.setDragEnabled(True)
self.tag_groups_list.setAcceptDrops(False)
self.tag_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
availableDeviceResources.setMinimumWidth(250)
availableDeviceResources.resize(250, availableDeviceResources.height())
self.verticalLayout.addWidget(self.tag_groups_list)
QMetaObject.connectSlotsByName(availableDeviceResources)

View File

@@ -1,143 +0,0 @@
from __future__ import annotations
import operator
from functools import reduce
from glob import glob
from pathlib import Path
from textwrap import dedent
from typing import AbstractSet, Protocol
from bec_lib.atlas_models import Device
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import model_validator
class HashableDevice(Device):
source_files: set[str] = set()
names: set[str] = set()
@model_validator(mode="after")
def add_name(self) -> HashableDevice:
self.names.add(self.name)
return self
def as_normal_device(self):
return Device.model_validate(self)
def __hash__(self) -> int:
config_values = sorted(
(str(kv) for kv in self.deviceConfig.items()) if self.deviceConfig else []
)
return (reduce(operator.add, (self.name, self.deviceClass, *config_values))).__hash__()
def __eq__(self, value: object) -> bool:
if not isinstance(value, self.__class__):
return False
if hash(self) == hash(value):
return True
return False
def rich_text(self) -> str:
return dedent(
f"""
<b><u><h2> {self.name}: </h2></u></b>
<table>
<tr><td> description: </td><td><i> {self.description} </i></td></tr>
<tr><td> config: </td><td><i> {self.deviceConfig} </i></td></tr>
<tr><td> enabled: </td><td><i> {self.enabled} </i></td></tr>
<tr><td> read only: </td><td><i> {self.readOnly} </i></td></tr>
</table>
"""
)
def add_sources(self, other: HashableDevice):
self.source_files.update(other.source_files)
def add_tags(self, other: HashableDevice):
self.deviceTags.update(other.deviceTags)
def add_names(self, other: HashableDevice):
self.names.update(other.names)
class _HashableDeviceSet(set):
def __or__(self, value: AbstractSet) -> _HashableDeviceSet:
for item in self:
if item in value:
for other_item in value:
if other_item == item:
item.add_sources(other_item)
item.add_tags(other_item)
item.add_names(other_item)
for other_item in value:
if other_item not in self:
self.add(other_item)
return self
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."""
...
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."""
...
class _ConfigFileBackend(DeviceResourceBackend):
def __init__(self) -> None:
self._raw_device_set: set[HashableDevice] = self._get_config_from_files(
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
)
self._tag_groups = self._get_tag_groups()
def _get_config_from_files(self, dir: Path):
files = glob("*.yaml", root_dir=dir, recursive=True)
def devices_from_file(file: str):
data = yaml_load(str(dir / file))
return set(
HashableDevice.model_validate(
dev | {"name": name, "source_files": {str(dir / file)}}
)
for name, dev in data.items()
)
return reduce(operator.or_, map(devices_from_file, files))
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._tag_groups
@property
def all_devices(self):
return self._raw_device_set
def tags(self) -> set[str]:
return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set))
def tag_group(self, tag: str) -> set[HashableDevice]:
return self.tag_groups[tag]
def get_backend() -> DeviceResourceBackend:
return _ConfigFileBackend()

View File

@@ -1,163 +0,0 @@
from textwrap import dedent
from typing import Callable, NamedTuple
from bec_qthemes import material_icon
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group_item_ui import (
Ui_DeviceTagGroup,
)
DEVICE_HASH_ROLE = 101
class _DeviceEntryWidget(QFrame):
_grid_size = QSize(120, 80)
def __init__(self, device_spec: HashableDevice, parent=None, **kwargs):
super().__init__(parent, **kwargs)
self._device_spec = device_spec
self.included: bool = False
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(5, 5, 5, 5)
self.setLayout(self._layout)
self.setMinimumSize(self._grid_size)
self.setup_title_layout(device_spec)
self.setToolTip(device_spec.rich_text())
self.details = QLabel(f"Tags:\n{', '.join(device_spec.deviceTags)}")
self.details.setStyleSheet("QLabel { font-size: 8pt; }")
self.details.setWordWrap(True)
self._layout.addWidget(self.details)
def setup_title_layout(self, device_spec: HashableDevice):
self._title_layout = QHBoxLayout()
self._title_container = QWidget(parent=self)
self._title_container.setLayout(self._title_layout)
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._warning_label = QLabel()
self._title_layout.addWidget(self._warning_label)
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", color="#FFAA00"))
self._warning_label.setToolTip(
dedent(
f"""
{f"Device has multiple names! Please check! \n names: {self._device_spec.names}" if len(self._device_spec.names)>1 else ""}
{f"Device found in multiple source files! Please check! \n files: {self._device_spec.names}" if len(self._device_spec.names)>1 else ""}
"""
)
)
@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 DeviceTagGroup(QWidget, Ui_DeviceTagGroup):
def __init__(
self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs
):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self.device_list.setGridSize(_DeviceEntryWidget._grid_size)
self.title.setText(name)
self._devices: dict[str, _DeviceEntry] = {}
for device in data:
self._add_item(device)
self.device_list.sortItems()
def _add_item(self, device: HashableDevice):
item = QListWidgetItem(self.device_list)
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 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 resizeEvent(self, event):
super().resizeEvent(event)
self.setMinimumHeight(self.sizeHint().height())
self.setMaximumHeight(self.sizeHint().height())
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 = DeviceTagGroup(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())

View File

@@ -1,135 +0,0 @@
import math
from functools import partial
from bec_qthemes import material_icon
from qtpy.QtCore import QMetaObject, QSize, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QListView,
QListWidget,
QSizePolicy,
QSpacerItem,
QToolButton,
QVBoxLayout,
)
class AutoHeightListWidget(QListWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setViewMode(QListView.ViewMode.IconMode)
self.setResizeMode(QListView.ResizeMode.Adjust)
self.setWrapping(True)
self.setUniformItemSizes(True)
self.setMovement(QListView.Movement.Static)
self.setAcceptDrops(False)
self.setDragEnabled(True)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSpacing(5)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
def resizeEvent(self, event):
super().resizeEvent(event)
self.setMinimumHeight(self._calcSize().height())
self.setMaximumHeight(self._calcSize().height())
def sizeHint(self):
return self._calcSize()
def minimumSizeHint(self):
return self._calcSize()
def _calcSize(self):
if self.count() == 0:
return super().sizeHint()
grid = self.gridSize()
if not grid.isValid():
grid = QSize(100, 100) # fallback
items_per_row = max(1, self.viewport().width() // grid.width())
rows = math.ceil(self.count() / items_per_row)
height = rows * grid.height() + 2 * self.frameWidth()
return QSize(self.viewport().width(), height)
class Ui_DeviceTagGroup(object):
def setupUi(self, DeviceTagGroup):
if not DeviceTagGroup.objectName():
DeviceTagGroup.setObjectName("DeviceTagGroup")
DeviceTagGroup.setMinimumWidth(150)
self.verticalLayout = QVBoxLayout(DeviceTagGroup)
self.verticalLayout.setObjectName("verticalLayout")
self.frame = QFrame(DeviceTagGroup)
self.frame.setObjectName("frame")
self.frame.setFrameShape(QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QFrame.Shadow.Raised)
self.verticalLayout_2 = QVBoxLayout(self.frame)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.title = QLabel(self.frame)
self.title.setObjectName("title")
self.horizontalLayout.addWidget(self.title)
self.n_included = QLabel(self.frame, text="...")
self.n_included.setObjectName("n_included")
self.horizontalLayout.addWidget(self.n_included)
self.horizontalSpacer = QSpacerItem(
40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
)
self.horizontalLayout.addItem(self.horizontalSpacer)
self.delete_tag_button = QToolButton(self.frame)
self.delete_tag_button.setObjectName("delete_tag_button")
self.horizontalLayout.addWidget(self.delete_tag_button)
self.remove_from_composition_button = QToolButton(self.frame)
self.remove_from_composition_button.setObjectName("remove_from_composition_button")
self.horizontalLayout.addWidget(self.remove_from_composition_button)
self.add_to_composition_button = QToolButton(self.frame)
self.add_to_composition_button.setObjectName("add_to_composition_button")
self.horizontalLayout.addWidget(self.add_to_composition_button)
self.remove_all_button = QToolButton(self.frame)
self.remove_all_button.setObjectName("remove_all_from_composition_button")
self.horizontalLayout.addWidget(self.remove_all_button)
self.add_all_button = QToolButton(self.frame)
self.add_all_button.setObjectName("add_all_to_composition_button")
self.horizontalLayout.addWidget(self.add_all_button)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.device_list = AutoHeightListWidget(self.frame)
self.device_list.setObjectName("device_list")
self.verticalLayout_2.addWidget(self.device_list)
self.verticalLayout.addWidget(self.frame)
self.set_icons()
QMetaObject.connectSlotsByName(DeviceTagGroup)
def set_icons(self):
icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False)
self.delete_tag_button.setIcon(icon("delete"))
self.delete_tag_button.setToolTip("Delete tag group")
self.remove_from_composition_button.setIcon(icon("remove"))
self.remove_from_composition_button.setToolTip("Remove selected from composition")
self.add_to_composition_button.setIcon(icon("add"))
self.add_to_composition_button.setToolTip("Add selected to composition")
self.remove_all_button.setIcon(icon("chips"))
self.remove_all_button.setToolTip("Remove all with this tag from composition")
self.add_all_button.setIcon(icon("add_box"))
self.add_all_button.setToolTip("Add all with this tag to composition")

View File

@@ -0,0 +1,631 @@
"""Module with the device table view implementation."""
from __future__ import annotations
import copy
import json
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from thefuzz import fuzz
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
logger = bec_logger.logger
# Threshold for fuzzy matching, careful with adjusting this. 80 seems good
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)
return True
class CenterCheckBoxDelegate(DictToolTipDelegate):
"""Custom checkbox delegate to center checkboxes in table cells."""
def __init__(self, parent=None):
super().__init__(parent)
colors = get_accent_colors()
self._icon_checked = material_icon(
"check_box", size=QtCore.QSize(16, 16), color=colors.default
)
self._icon_unchecked = material_icon(
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default
)
def apply_theme(self, theme: str | None = None):
colors = get_accent_colors()
self._icon_checked.setColor(colors.default)
self._icon_unchecked.setColor(colors.default)
def paint(self, painter, option, index):
value = index.model().data(index, QtCore.Qt.CheckStateRole)
if value is None:
super().paint(painter, option, index)
return
# Choose icon based on state
pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked
# Draw icon centered
rect = option.rect
pix_rect = pixmap.rect()
pix_rect.moveCenter(rect.center())
painter.drawPixmap(pix_rect.topLeft(), pixmap)
def editorEvent(self, event, model, option, index):
if event.type() != QtCore.QEvent.MouseButtonRelease:
return False
current = model.data(index, QtCore.Qt.CheckStateRole)
new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
class WrappingTextDelegate(DictToolTipDelegate):
"""Custom delegate for wrapping text in table cells."""
def paint(self, painter, option, index):
text = index.model().data(index, QtCore.Qt.DisplayRole)
if not text:
return super().paint(painter, option, index)
painter.save()
painter.setClipRect(option.rect)
text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop
painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text)
painter.restore()
def sizeHint(self, option, index):
text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
# if not text:
# return super().sizeHint(option, index)
# Use the actual column width
table = index.model().parent() # or store reference to QTableView
column_width = table.columnWidth(index.column()) # - 8
doc = QtGui.QTextDocument()
doc.setDefaultFont(option.font)
doc.setTextWidth(column_width)
doc.setPlainText(text)
layout_height = doc.documentLayout().documentSize().height()
height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off
return QtCore.QSize(column_width, height)
class DeviceTableModel(QtCore.QAbstractTableModel):
"""
Custom Device Table Model for managing device configurations.
Sort logic is implemented directly on the data of the table view.
"""
def __init__(self, device_config: list[dict] | None = None, parent=None):
super().__init__(parent)
self._device_config = device_config or []
self.headers = [
"name",
"deviceClass",
"readoutPriority",
"enabled",
"readOnly",
"deviceTags",
"description",
]
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
###############################################
########## Overwrite custom Qt methods ########
###############################################
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self._device_config)
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self.headers)
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal:
return self.headers[section]
return None
def row_data(self, index: QtCore.QModelIndex) -> dict:
"""Return the row data for the given index."""
if not index.isValid():
return {}
return copy.deepcopy(self._device_config[index.row()])
def data(self, index, role=QtCore.Qt.DisplayRole):
"""Return data for the given index and role."""
if not index.isValid():
return None
row, col = index.row(), index.column()
key = self.headers[col]
value = self._device_config[row].get(key)
if role == QtCore.Qt.DisplayRole:
if key in ("enabled", "readOnly"):
return bool(value)
if key == "deviceTags":
return ", ".join(str(tag) for tag in value) if value else ""
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
if role == QtCore.Qt.TextAlignmentRole:
if key in ("enabled", "readOnly"):
return QtCore.Qt.AlignCenter
return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
if role == QtCore.Qt.FontRole:
font = QtGui.QFont()
return font
return None
def flags(self, index):
"""Flags for the table model."""
if not index.isValid():
return QtCore.Qt.NoItemFlags
key = self.headers[index.column()]
if key in ("enabled", "readOnly"):
base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
if self._checkable_columns_enabled.get(key, True):
return base_flags | QtCore.Qt.ItemIsUserCheckable
else:
return base_flags # disable editing but still visible
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool:
"""
Method to set the data of the table.
Args:
index (QModelIndex): The index of the item to modify.
value (Any): The new value to set.
role (Qt.ItemDataRole): The role of the data being set.
Returns:
bool: True if the data was set successfully, False otherwise.
"""
if not index.isValid():
return False
key = self.headers[index.column()]
row = index.row()
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
if not self._checkable_columns_enabled.get(key, True):
return False # ignore changes if column is disabled
self._device_config[row][key] = value == QtCore.Qt.Checked
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
return True
return False
####################################
############ Public methods ########
####################################
def get_device_config(self) -> list[dict]:
"""Return the current device config (with checkbox updates applied)."""
return self._device_config
def set_checkbox_enabled(self, column_name: str, enabled: bool):
"""
Enable/Disable the checkbox column.
Args:
column_name (str): The name of the column to modify.
enabled (bool): Whether the checkbox should be enabled or disabled.
"""
if column_name in self._checkable_columns_enabled:
self._checkable_columns_enabled[column_name] = enabled
col = self.headers.index(column_name)
top_left = self.index(0, col)
bottom_right = self.index(self.rowCount() - 1, col)
self.dataChanged.emit(
top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole]
)
def set_device_config(self, device_config: list[dict]):
"""
Replace the device config.
Args:
device_config (list[dict]): The new device config to set.
"""
self.beginResetModel()
self._device_config = list(device_config)
self.endResetModel()
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add an extra device to the device config at the bottom.
Args:
device (dict): The device configuration to add.
"""
row = len(self._device_config)
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self._device_config.append(device)
self.endInsertRows()
@SafeSlot(int)
def remove_device_by_row(self, row: int):
"""
Remove one device row by index. This maps to the row to the source of the data model
Args:
row (int): The index of the device row to remove.
"""
if 0 <= row < len(self._device_config):
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self._device_config.pop(row)
self.endRemoveRows()
@SafeSlot(list)
def remove_devices_by_rows(self, rows: list[int]):
"""
Remove multiple device rows by their indices.
Args:
rows (list[int]): The indices of the device rows to remove.
"""
for row in sorted(rows, reverse=True):
self.remove_device_by_row(row)
@SafeSlot(str)
def remove_device_by_name(self, name: str):
"""
Remove one device row by name.
Args:
name (str): The name of the device to remove.
"""
for row, device in enumerate(self._device_config):
if device.get("name") == name:
self.remove_device_by_row(row)
break
class BECTableView(QtWidgets.QTableView):
"""Table View with custom keyPressEvent to delete rows with backspace or delete key"""
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""
if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
return super().keyPressEvent(event)
proxy_indexes = self.selectedIndexes()
if not proxy_indexes:
return
# Get unique rows (proxy indices) in reverse order so removal indexes stay valid
proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True)
# Map to source model rows
source_rows = [
self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows
]
model: DeviceTableModel = self.model().sourceModel() # access underlying model
# Delegate confirmation and removal to helper
removed = self._confirm_and_remove_rows(model, source_rows)
if not removed:
return
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
"""
Prompt the user to confirm removal of rows and remove them from the model if accepted.
Returns True if rows were removed, False otherwise.
"""
cfg = model.get_device_config()
names = [str(cfg[r].get("name", "<unknown>")) for r in sorted(source_rows)]
msg = QtWidgets.QMessageBox(self)
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.setWindowTitle("Confirm remove devices")
if len(names) == 1:
msg.setText(f"Remove device '{names[0]}'?")
else:
msg.setText(f"Remove {len(names)} devices?")
msg.setInformativeText("\n".join(names))
msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
msg.setDefaultButton(QtWidgets.QMessageBox.Cancel)
res = msg.exec_()
if res == QtWidgets.QMessageBox.Ok:
model.remove_devices_by_rows(source_rows)
# TODO add signal for removed devices
return True
return False
class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._hidden_rows = set()
self._filter_text = ""
self._enable_fuzzy = True
self._filter_columns = [0, 1] # name and deviceClass for search
def hide_rows(self, row_indices: list[int]):
"""
Hide specific rows in the model.
Args:
row_indices (list[int]): List of row indices to hide.
"""
self._hidden_rows.update(row_indices)
self.invalidateFilter()
def show_rows(self, row_indices: list[int]):
"""
Show specific rows in the model.
Args:
row_indices (list[int]): List of row indices to show.
"""
self._hidden_rows.difference_update(row_indices)
self.invalidateFilter()
def show_all_rows(self):
"""
Show all rows in the model.
"""
self._hidden_rows.clear()
self.invalidateFilter()
@SafeSlot(int)
def disable_fuzzy_search(self, enabled: int):
self._enable_fuzzy = not bool(enabled)
self.invalidateFilter()
def setFilterText(self, text: str):
self._filter_text = text.lower()
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No hidden rows, and no filter text
if not self._filter_text and not self._hidden_rows:
return True
# Hide hidden rows
if source_row in self._hidden_rows:
return False
# Check the filter text for each row
model = self.sourceModel()
text = self._filter_text.lower()
for column in self._filter_columns:
index = model.index(source_row, column, source_parent)
data = str(model.data(index, QtCore.Qt.DisplayRole) or "")
if self._enable_fuzzy is True:
match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text in data.lower():
return True
return False
class DeviceTableView(BECWidget, QtWidgets.QWidget):
"""Device Table View for the device manager."""
RPC = False
PLUGIN = False
devices_removed = QtCore.Signal(list)
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.layout = QtWidgets.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
# Setup table view
self._setup_table_view()
# Setup search view, needs table proxy to be iniditate
self._setup_search()
# Add widgets to main layout
self.layout.addLayout(self.search_controls)
self.layout.addWidget(self.table)
def _setup_search(self):
"""Create components related to the search functionality"""
# Create search bar
self.search_layout = QtWidgets.QHBoxLayout()
self.search_label = QtWidgets.QLabel("Search:")
self.search_input = QtWidgets.QLineEdit()
self.search_input.setPlaceholderText(
"Filter devices (approximate matching)..."
) # Default to fuzzy search
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.proxy.setFilterText)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
# Add exact match toggle
self.fuzzy_layout = QtWidgets.QHBoxLayout()
self.fuzzy_label = QtWidgets.QLabel("Exact Match:")
self.fuzzy_is_disabled = QtWidgets.QCheckBox()
self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search)
self.fuzzy_is_disabled.setToolTip(
"Enable approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
self.fuzzy_layout.addWidget(self.fuzzy_label)
self.fuzzy_layout.addWidget(self.fuzzy_is_disabled)
self.fuzzy_layout.addStretch()
# Add both search components to the layout
self.search_controls = QtWidgets.QHBoxLayout()
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_layout)
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
def _setup_table_view(self) -> None:
"""Setup the table view."""
# Model + Proxy
self.table = BECTableView(self)
self.model = DeviceTableModel(parent=self.table)
self.proxy = DeviceFilterProxyModel(parent=self.table)
self.proxy.setSourceModel(self.model)
self.table.setModel(self.proxy)
self.table.setSortingEnabled(True)
# Delegates
self.checkbox_delegate = CenterCheckBoxDelegate(self.table)
self.wrap_delegate = WrappingTextDelegate(self.table)
self.tool_tip_delegate = DictToolTipDelegate(self.table)
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
# Column resize policies
# TODO maybe we need here a flexible header options as deviceClass
# may get quite long for beamlines plugin repos
header = self.table.horizontalHeader()
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)
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
header.setDefaultSectionSize(90)
# Enable resizing of column
header.sectionResized.connect(self.on_table_resized)
# Selection behavior
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.table.horizontalHeader().setHighlightSections(False)
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
def device_config(self) -> list[dict]:
"""Get the device config."""
return self.model.get_device_config()
def apply_theme(self, theme: str | None = None):
self.checkbox_delegate.apply_theme(theme)
######################################
########### Slot API #################
######################################
@SafeSlot(int, int, int)
def on_table_resized(self, column, old_width, new_width):
"""Handle changes to the table column resizing."""
if column != len(self.model.headers) - 1:
return
for row in range(self.table.model().rowCount()):
index = self.table.model().index(row, column)
delegate = self.table.itemDelegate(index)
option = QtWidgets.QStyleOptionViewItem()
height = delegate.sizeHint(option, index).height()
self.table.setRowHeight(row, height)
######################################
##### Ext. Slot API #################
######################################
@SafeSlot(list)
def set_device_config(self, config: list[dict]):
"""
Set the device config.
Args:
config (list[dict]): The device config to set.
"""
self.model.set_device_config(config)
@SafeSlot()
def clear_device_config(self):
"""
Clear the device config.
"""
self.model.set_device_config([])
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add a device to the config.
Args:
device (dict): The device to add.
"""
self.model.add_device(device)
@SafeSlot(int)
@SafeSlot(str)
def remove_device(self, dev: int | str):
"""
Remove the device from the config either by row id, or device name.
Args:
dev (int | str): The device to remove, either by row id or device name.
"""
if isinstance(dev, int):
# TODO test this properly, check with proxy index and source index
# Use the proxy model to map to the correct row
model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0))
self.model.remove_device_by_row(model_source_index.row())
return
if isinstance(dev, str):
self.model.remove_device_by_name(dev)
return
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DeviceTableView()
# pylint: disable=protected-access
config = window.client.device_manager._get_redis_device_config()
window.set_device_config(config)
window.show()
sys.exit(app.exec_())

View File

@@ -2,31 +2,3 @@
This module provides an implementation for the device config view.
The widget is the entry point for users to edit device configurations.
"""
from qtpy import QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
class DeviceConfigView(BECWidget, QtWidgets.QTreeWidget):
"""
DeviceConfigView is a widget that allows users to view device configurations.
It inherits from BECWidget and QtWidgets.QTreeWidget.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
self.column_header
self.setup_ui()
def setup_ui(self):
"""
Set up the user interface for the device configuration view.
This method initializes the layout and widgets needed for displaying
and editing device configurations.
"""
# Initialize layout and widgets here
pass

View File

@@ -1,4 +1,4 @@
from typing import Literal
from typing import Literal, Sequence
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -36,7 +36,7 @@ class ScanArgType:
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
LITERALS_DICT = "dict" # Used when the type is provided as a dict with Literal key
class SettingsDialog(QDialog):
@@ -83,6 +83,39 @@ class ScanSpinBox(QSpinBox):
self.setValue(default)
class ScanLiteralsComboBox(QComboBox):
def __init__(
self, parent=None, arg_name: str | None = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.default = default
if default is not None:
self.setCurrentText(default)
def set_literals(self, literals: Sequence[str | int | float | None]) -> None:
"""
Set the list of literals for the combo box.
Args:
literals: List of literal values (can be strings, integers, floats or None)
"""
self.clear()
literals = set(literals) # Remove duplicates
if None in literals:
literals.remove(None)
self.addItem("")
self.addItems([str(value) for value in literals])
# find index of the default value
index = max(self.findText(str(self.default)), 0)
self.setCurrentIndex(index)
def get_value(self) -> str | None:
return self.currentText() if self.currentText() else None
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
@@ -137,7 +170,7 @@ class ScanGroupBox(QGroupBox):
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
}
device_selected = Signal(str)
@@ -226,7 +259,11 @@ class ScanGroupBox(QGroupBox):
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
default = item.get("default", None)
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
item_type = item.get("type", None)
if isinstance(item_type, dict) and "Literal" in item_type:
widget_class = self.WIDGET_HANDLER.get(ScanArgType.LITERALS_DICT, None)
else:
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
if widget_class is None:
logger.error(
f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'"
@@ -239,6 +276,8 @@ class ScanGroupBox(QGroupBox):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget.setToolTip(item["tooltip"])
@@ -336,6 +375,8 @@ class ScanGroupBox(QGroupBox):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit) and device_object:
value = widget.get_current_device().name
elif isinstance(widget, ScanLiteralsComboBox):
value = widget.get_value()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value

View File

@@ -0,0 +1,146 @@
import datetime
import importlib
import os
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
from bec_widgets.widgets.containers.explorer.explorer import Explorer
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
class IDEExplorer(BECWidget, QWidget):
"""Integrated Development Environment Explorer"""
PLUGIN = True
RPC = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._sections = set()
self.main_explorer = Explorer(parent=self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.main_explorer)
self.setLayout(layout)
self.sections = ["scripts"]
@SafeProperty(list)
def sections(self):
return list(self._sections)
@sections.setter
def sections(self, value):
existing_sections = set(self._sections)
self._sections = set(value)
self._update_section_visibility(self._sections - existing_sections)
def _update_section_visibility(self, sections):
for section in sections:
self._add_section(section)
def _add_section(self, section_name):
match section_name.lower():
case "scripts":
self.add_script_section()
case _:
pass
def add_script_section(self):
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
section.expanded = False
script_explorer = Explorer(parent=self)
script_widget = ScriptTreeWidget(parent=self)
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
local_scripts_section.set_widget(script_widget)
local_script_dir = self.client._service_config.model.user_scripts.base_path
if not os.path.exists(local_script_dir):
os.makedirs(local_script_dir)
script_widget.set_directory(local_script_dir)
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
self.main_explorer.add_section(section)
plugin_scripts_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
break
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
return
shared_script_section = CollapsibleSection(title="Shared", parent=self)
shared_script_widget = ScriptTreeWidget(parent=self)
shared_script_section.set_widget(shared_script_widget)
shared_script_widget.set_directory(plugin_scripts_dir)
script_explorer.add_section(shared_script_section)
# macros_section = CollapsibleSection("MACROS", indentation=0)
# macros_section.set_widget(QLabel("Macros will be implemented later"))
# self.main_explorer.add_section(macros_section)
def _add_local_script(self):
"""Show a dialog to enter the name of a new script and create it."""
target_section = self.main_explorer.get_section("SCRIPTS")
script_dir_section = target_section.content_widget.get_section("Local")
local_script_dir = script_dir_section.content_widget.directory
# Prompt user for filename
filename, ok = QInputDialog.getText(
self, "New Script", f"Enter script name ({local_script_dir}/<filename>):"
)
if not ok or not filename:
return # User cancelled or didn't enter a name
# Add .py extension if not already present
if not filename.endswith(".py"):
filename = f"{filename}.py"
file_path = os.path.join(local_script_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
response = QMessageBox.question(
self,
"File exists",
f"The file '{filename}' already exists. Do you want to overwrite it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if response != QMessageBox.StandardButton.Yes:
return # User chose not to overwrite
try:
# Create the file with a basic template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f"""
\"\"\"
{filename} - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
\"\"\"
"""
)
except Exception as e:
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication([])
script_explorer = IDEExplorer()
script_explorer.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['ide_explorer.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
DOM_XML = """
<ui language='c++'>
<widget class='IDEExplorer' name='ide_explorer'>
</widget>
</ui>
"""
class IDEExplorerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = IDEExplorer(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(IDEExplorer.ICON_NAME)
def includeFile(self):
return "ide_explorer"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "IDEExplorer"
def toolTip(self):
return "Integrated Development Environment Explorer"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.ide_explorer.ide_explorer_plugin import IDEExplorerPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(IDEExplorerPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.35.0"
version = "2.38.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -24,6 +24,7 @@ dependencies = [
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco~=0.5",
"thefuzz~=0.22",
]
@@ -41,6 +42,7 @@ dev = [
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
]
[project.urls]

View File

@@ -1,78 +0,0 @@
from copy import copy
import pytest
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
_HashableDeviceSet,
)
TEST_DEVICE_DICT = {
"name": "test_device",
"deviceClass": "TestDeviceClass",
"readoutPriority": "baseline",
"enabled": True,
}
def _test_device_dict(**kwargs):
new = copy(TEST_DEVICE_DICT)
new.update(kwargs)
return new
@pytest.mark.parametrize(
"kwargs_1, kwargs_2, kwargs_3, kwargs_4, n",
[
({}, {}, {}, {}, 1),
({}, {}, {}, {"deviceConfig": {"a": 1}}, 1),
({}, {}, {}, {"name": "test_device_2"}, 2),
({}, {}, {"name": "test_device_2"}, {"deviceClass": "OtherDeviceClass"}, 3),
],
)
def test_hashable_device_set_merges_equal(kwargs_1, kwargs_2, kwargs_3, kwargs_4, n):
item_1 = HashableDevice(**_test_device_dict(**kwargs_1))
item_2 = HashableDevice(**_test_device_dict(**kwargs_2))
item_3 = HashableDevice(**_test_device_dict(**kwargs_3))
item_4 = HashableDevice(**_test_device_dict(**kwargs_4))
test_set = _HashableDeviceSet((item_1, item_2, item_3, item_4))
assert len(test_set) == n
def test_hashable_device_set_or_adds_sources():
item_1 = HashableDevice(**_test_device_dict(), source_files={"a", "b"})
item_2 = HashableDevice(**_test_device_dict(), source_files={"c", "d"})
set_1 = _HashableDeviceSet((item_1,))
set_2 = _HashableDeviceSet((item_2,))
combined = set_1 | set_2
assert len(combined) == 1
assert combined.pop().source_files == {"a", "b", "c", "d"}
def test_hashable_device_set_or_adds_tags():
item_1 = HashableDevice(
**_test_device_dict(deviceTags={"tag1"}, deviceConfig={"param": "value"}),
source_files={"a", "b"},
)
item_2 = HashableDevice(
**_test_device_dict(deviceTags={"tag2"}, deviceConfig={"param": "value"}),
source_files={"c", "d"},
)
item_3 = HashableDevice(
**_test_device_dict(deviceTags={"tag3"}, deviceConfig={"param": "other_value"}),
source_files={"q"},
)
set_1 = _HashableDeviceSet((item_1,))
set_2 = _HashableDeviceSet((item_2,))
set_3 = _HashableDeviceSet((item_3,))
combined = sorted(set_1 | set_2 | set_3, key=lambda hd: hd.deviceConfig["param"])
assert len(combined) == 2
assert combined[0].source_files == {"q"}
assert combined[0].deviceTags == {"tag3"}
assert combined[1].source_files == {"a", "b", "c", "d"}
assert combined[1].deviceTags == {"tag1", "tag2"}

View File

@@ -0,0 +1,55 @@
import pytest
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
from bec_widgets.widgets.containers.explorer.explorer import Explorer
@pytest.fixture
def explorer(qtbot):
widget = Explorer()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_explorer_initialization(explorer):
assert explorer is not None
assert len(explorer.sections) == 0
def test_add_remove_section(explorer, qtbot):
section = CollapsibleSection(title="Test Section", parent=explorer)
explorer.add_section(section)
assert len(explorer.sections) == 1
assert explorer.sections[0].title == "Test Section"
section2 = CollapsibleSection(title="Another Section", parent=explorer)
explorer.add_section(section2)
assert len(explorer.sections) == 2
assert explorer.sections[1].title == "Another Section"
explorer.remove_section(section)
assert len(explorer.sections) == 1
assert explorer.sections[0].title == "Another Section"
qtbot.wait(100) # Allow time for the section to be removed
assert explorer.splitter.count() == 1
def test_section_reorder(explorer):
section = CollapsibleSection(title="Section 1", parent=explorer)
explorer.add_section(section)
section2 = CollapsibleSection(title="Section 2", parent=explorer)
explorer.add_section(section2)
assert explorer.sections[0].title == "Section 1"
assert explorer.sections[1].title == "Section 2"
assert len(explorer.sections) == 2
assert explorer.splitter.count() == 2
explorer._handle_section_reorder("Section 1", "Section 2")
assert explorer.sections[0].title == "Section 2"
assert explorer.sections[1].title == "Section 1"
assert len(explorer.sections) == 2
assert explorer.splitter.count() == 2

View File

@@ -0,0 +1,36 @@
import os
from unittest import mock
import pytest
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@pytest.fixture
def ide_explorer(qtbot, tmpdir):
"""Create an IDEExplorer widget for testing"""
widget = IDEExplorer()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_ide_explorer_initialization(ide_explorer):
"""Test the initialization of the IDEExplorer widget"""
assert ide_explorer is not None
assert "scripts" in ide_explorer.sections
assert ide_explorer.main_explorer.sections[0].title == "SCRIPTS"
def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir):
local_script_section = ide_explorer.main_explorer.get_section(
"SCRIPTS"
).content_widget.get_section("Local")
local_script_section.content_widget.set_directory(str(tmpdir))
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("test_file.py", True),
):
ide_explorer._add_local_script()
assert os.path.exists(os.path.join(tmpdir, "test_file.py"))

View File

@@ -210,6 +210,15 @@ available_scans_message = AvailableResourceMessage(
"default": False,
"expert": False,
},
{
"arg": False,
"name": "optim_trajectory",
"type": {"Literal": ("option1", "option2", "option3", None)},
"display_name": "Optim Trajectory",
"tooltip": None,
"default": None,
"expert": False,
},
],
}
],
@@ -304,7 +313,10 @@ def test_on_scan_selected(scan_control, scan_name):
label = kwarg_box.layout.itemAtPosition(0, index).widget()
assert label.text() == kwarg_info["display_name"]
widget = kwarg_box.layout.itemAtPosition(1, index).widget()
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
if isinstance(kwarg_info["type"], dict) and "Literal" in kwarg_info["type"]:
expected_widget_type = kwarg_box.WIDGET_HANDLER.get("dict", None)
else:
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
assert isinstance(widget, expected_widget_type)
@@ -441,7 +453,7 @@ def test_run_grid_scan_with_parameters(scan_control, mocked_client):
args_row2["steps"],
]
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}, "optim_trajectory": None}
# Check the emitted signal
mock_slot.assert_called_once()

View File

@@ -0,0 +1,118 @@
from pathlib import Path
import pytest
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QMouseEvent
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
@pytest.fixture
def script_tree(qtbot, tmpdir):
"""Create a ScriptTreeWidget with the tmpdir directory"""
# Create test files and directories
(Path(tmpdir) / "test_file.py").touch()
(Path(tmpdir) / "test_dir").mkdir()
(Path(tmpdir) / "test_dir" / "nested_file.py").touch()
widget = ScriptTreeWidget()
widget.set_directory(str(tmpdir))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_script_tree_set_directory(script_tree, tmpdir):
"""Test setting the directory"""
assert script_tree.directory == str(tmpdir)
def test_script_tree_hover_events(script_tree, qtbot):
"""Test mouse hover events and actions button visibility"""
# Get the tree view and its viewport
tree_view = script_tree.tree
viewport = tree_view.viewport()
# Find the position of the first item (test_file.py)
index = script_tree.proxy_model.index(0, 0) # first item
rect = tree_view.visualRect(index)
pos = rect.center()
# Initially, no item should be hovered
assert script_tree.delegate.hovered_index.isValid() == False
# Simulate a mouse move event over the item
mouse_event = QMouseEvent(
QEvent.Type.MouseMove,
pos,
tree_view.mapToGlobal(pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# Send the event to the viewport (the event filter is installed on the viewport)
script_tree.eventFilter(viewport, mouse_event)
qtbot.wait(100) # Allow time for the hover to be processed
# Now, the hover index should be set to the first item
assert script_tree.delegate.hovered_index.isValid() == True
assert script_tree.delegate.hovered_index.row() == index.row()
# Simulate mouse leaving the viewport
leave_event = QEvent(QEvent.Type.Leave)
script_tree.eventFilter(viewport, leave_event)
qtbot.wait(100) # Allow time for the leave event to be processed
# After leaving, no item should be hovered
assert script_tree.delegate.hovered_index.isValid() == False
@pytest.mark.timeout(10)
def test_script_tree_on_item_clicked(script_tree, qtbot, tmpdir):
"""Test that _on_item_clicked emits file_selected signal only for Python files"""
file_selected_signals = []
file_open_requested_signals = []
def on_file_selected(file_path):
file_selected_signals.append(file_path)
def on_file_open_requested(file_path):
file_open_requested_signals.append(file_path)
# Connect to the signal
script_tree.file_selected.connect(on_file_selected)
script_tree.file_open_requested.connect(on_file_open_requested)
# Wait until the model sees test_file.py
def has_py_file():
nonlocal py_file_index
root_index = script_tree.tree.rootIndex()
for i in range(script_tree.proxy_model.rowCount(root_index)):
index = script_tree.proxy_model.index(i, 0, root_index)
source_index = script_tree.proxy_model.mapToSource(index)
if script_tree.model.fileName(source_index) == "test_file.py":
py_file_index = index
return True
return False
py_file_index = None
qtbot.waitUntil(has_py_file)
# Simulate clicking on the center of the item
script_tree._on_item_clicked(py_file_index)
qtbot.wait(100) # Allow time for the click to be processed
py_file_index = None
qtbot.waitUntil(has_py_file)
script_tree._on_item_double_clicked(py_file_index)
qtbot.wait(100)
# Verify the signal was emitted with the correct path
assert len(file_selected_signals) == 1
assert Path(file_selected_signals[0]).name == "test_file.py"

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Some files were not shown because too many files have changed in this diff Show More