mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-15 13:10:54 +02:00
Compare commits
1 Commits
v2.37.0
...
scratch/de
| Author | SHA1 | Date | |
|---|---|---|---|
| fde7b4db6c |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,22 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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()
|
||||
@@ -1,179 +0,0 @@
|
||||
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()
|
||||
@@ -1,387 +0,0 @@
|
||||
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()
|
||||
998
bec_widgets/widgets/control/device_manager/device_manager.py
Normal file
998
bec_widgets/widgets/control/device_manager/device_manager.py
Normal file
@@ -0,0 +1,998 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QSize, QSortFilterProxyModel, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.bec_table import BECTable
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
class CheckBoxCenterWidget(QWidget):
|
||||
"""Widget to center a checkbox in a table cell."""
|
||||
|
||||
def __init__(self, checked=False, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
layout.setContentsMargins(4, 0, 4, 0) # Reduced margins for more compact layout
|
||||
|
||||
self.checkbox = QCheckBox()
|
||||
self.checkbox.setChecked(checked)
|
||||
self.checkbox.setEnabled(False) # Read-only
|
||||
|
||||
# Store the value for sorting
|
||||
self.value = checked
|
||||
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
|
||||
class TextLabelWidget(QWidget):
|
||||
"""Widget to display text with word wrapping in a table cell."""
|
||||
|
||||
def __init__(self, text="", parent=None):
|
||||
super().__init__(parent)
|
||||
# Use a layout with minimal margins to maximize text display area
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create label with word wrap enabled
|
||||
self.label = QLabel(text)
|
||||
self.label.setWordWrap(True)
|
||||
self.label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.label.setAlignment(Qt.AlignLeft | Qt.AlignTop) # Align to top-left
|
||||
|
||||
# Make sure label expands to fill available space
|
||||
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Store the text value for sorting
|
||||
self.value = text
|
||||
|
||||
layout.addWidget(self.label)
|
||||
|
||||
# Make sure the widget itself uses an expanding size policy
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
|
||||
|
||||
# Ensure we have a reasonable height to start with
|
||||
# This helps ensure text is visible before resizing calculations
|
||||
min_height = 40 if text else 20
|
||||
self.setMinimumHeight(min_height)
|
||||
|
||||
def setText(self, text):
|
||||
"""Set the text of the label."""
|
||||
self.label.setText(text)
|
||||
self.value = text
|
||||
# Trigger layout update
|
||||
self.updateGeometry()
|
||||
|
||||
def sizeHint(self):
|
||||
"""Provide a size hint based on the text content."""
|
||||
# Get the width of our container (usually the table cell)
|
||||
width = self.width() or 300
|
||||
|
||||
# If text is empty, return minimal size
|
||||
if not self.value:
|
||||
return QSize(width, 20)
|
||||
|
||||
# Calculate height for wrapped text
|
||||
font_metrics = self.label.fontMetrics()
|
||||
|
||||
# Estimate how much space the text will need when wrapped
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
width - 10,
|
||||
1000, # Width constraint, virtually unlimited height
|
||||
Qt.TextWordWrap,
|
||||
self.value,
|
||||
)
|
||||
|
||||
# Add some padding
|
||||
height = text_rect.height() + 8
|
||||
|
||||
return QSize(width, max(30, height))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle resize events to ensure text is properly displayed."""
|
||||
super().resizeEvent(event)
|
||||
|
||||
# When resized (especially width change), update layout to ensure text wrapping works
|
||||
self.label.updateGeometry()
|
||||
self.updateGeometry()
|
||||
|
||||
|
||||
class SortableTableWidgetItem(QTableWidgetItem):
|
||||
"""Table widget item that enables proper sorting for different data types."""
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare items for sorting."""
|
||||
if self.text() == "Yes" and other.text() == "No":
|
||||
return True
|
||||
elif self.text() == "No" and other.text() == "Yes":
|
||||
return False
|
||||
else:
|
||||
return self.text().lower() < other.text().lower()
|
||||
|
||||
|
||||
class DeviceTagsWidget(BECWidget, QWidget):
|
||||
"""Widget to display devices grouped by their tags in containers."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# Title
|
||||
self.title_label = QLabel("Device Tags")
|
||||
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
self.layout.addWidget(self.title_label)
|
||||
|
||||
# Search bar for tags
|
||||
self.search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Filter tags...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.filter_tags)
|
||||
self.search_layout.addWidget(self.search_label)
|
||||
self.search_layout.addWidget(self.search_input)
|
||||
self.layout.addLayout(self.search_layout)
|
||||
|
||||
# Create a scroll area for tag containers
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
|
||||
# Create a widget to hold all tag containers
|
||||
self.scroll_widget = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_widget)
|
||||
self.scroll_layout.setSpacing(10)
|
||||
self.scroll_layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.scroll_layout.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.scroll_area.setWidget(self.scroll_widget)
|
||||
self.layout.addWidget(self.scroll_area)
|
||||
|
||||
# Initialize with empty data
|
||||
self.all_devices = []
|
||||
self.active_devices = []
|
||||
self.device_tags = {} # Maps tag names to lists of device names
|
||||
self.tag_containers = {} # Maps tag names to their container widgets
|
||||
|
||||
# Load initial data
|
||||
self.update_tags()
|
||||
|
||||
def update_tags(self):
|
||||
"""Update the tags containers with current device information."""
|
||||
try:
|
||||
# Get device config
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
|
||||
# Clear current data
|
||||
self.all_devices = []
|
||||
self.active_devices = []
|
||||
self.device_tags = {}
|
||||
|
||||
# Process device config
|
||||
for device_info in config:
|
||||
device_name = device_info.get("name", "Unknown")
|
||||
self.all_devices.append(device_name)
|
||||
|
||||
# Add to active devices if enabled
|
||||
if device_info.get("enabled", False):
|
||||
self.active_devices.append(device_name)
|
||||
|
||||
# Process device tags
|
||||
tags = device_info.get("deviceTags", [])
|
||||
for tag in tags:
|
||||
if tag not in self.device_tags:
|
||||
self.device_tags[tag] = []
|
||||
self.device_tags[tag].append(device_name)
|
||||
|
||||
# Update the tag containers
|
||||
self.populate_tag_containers()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Tags Error", f"Error updating device tags: {str(e)}", self
|
||||
)
|
||||
|
||||
def populate_tag_containers(self):
|
||||
"""Populate the containers with current tag and device data."""
|
||||
# Save current filter before clearing
|
||||
current_filter = self.search_input.text() if hasattr(self, "search_input") else ""
|
||||
|
||||
# Clear existing containers
|
||||
for i in reversed(range(self.scroll_layout.count())):
|
||||
widget = self.scroll_layout.itemAt(i).widget()
|
||||
if widget:
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
|
||||
self.tag_containers = {}
|
||||
|
||||
# Add tag containers
|
||||
for tag, devices in sorted(self.device_tags.items()):
|
||||
# Create container frame for this tag
|
||||
container = QFrame()
|
||||
container.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
|
||||
container.setStyleSheet(
|
||||
"QFrame { background-color: palette(window); border: 1px solid palette(mid); border-radius: 4px; }"
|
||||
)
|
||||
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Add tag header with status indicator
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
# Tag name label
|
||||
tag_label = QLabel(tag)
|
||||
tag_label.setStyleSheet("font-weight: bold;")
|
||||
header_layout.addWidget(tag_label)
|
||||
|
||||
# Spacer to push status to the right
|
||||
header_layout.addStretch()
|
||||
|
||||
# Status indicator
|
||||
all_devices_count = len(devices)
|
||||
active_devices_count = sum(1 for d in devices if d in self.active_devices)
|
||||
|
||||
if active_devices_count == 0:
|
||||
status_text = "None"
|
||||
status_color = "red"
|
||||
elif active_devices_count == all_devices_count:
|
||||
status_text = "All"
|
||||
status_color = "green"
|
||||
else:
|
||||
status_text = f"{active_devices_count}/{all_devices_count}"
|
||||
status_color = "orange"
|
||||
|
||||
status_label = QLabel(status_text)
|
||||
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
|
||||
header_layout.addWidget(status_label)
|
||||
|
||||
container_layout.addLayout(header_layout)
|
||||
|
||||
# Add divider line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
container_layout.addWidget(line)
|
||||
|
||||
# Add device list
|
||||
device_list = QListWidget()
|
||||
device_list.setAlternatingRowColors(True)
|
||||
device_list.setMaximumHeight(150) # Limit height
|
||||
|
||||
# Add devices to the list
|
||||
for device_name in sorted(devices):
|
||||
item = QListWidgetItem(device_name)
|
||||
if device_name in self.active_devices:
|
||||
item.setForeground(Qt.green)
|
||||
else:
|
||||
item.setForeground(Qt.red)
|
||||
device_list.addItem(item)
|
||||
|
||||
container_layout.addWidget(device_list)
|
||||
|
||||
# Add to the scroll layout
|
||||
self.scroll_layout.addWidget(container)
|
||||
self.tag_containers[tag] = container
|
||||
|
||||
# Add a stretch at the end to push all containers to the top
|
||||
self.scroll_layout.addStretch()
|
||||
|
||||
# Reapply filter if there was one
|
||||
if current_filter:
|
||||
self.filter_tags(current_filter)
|
||||
|
||||
@SafeSlot(str)
|
||||
def filter_tags(self, text):
|
||||
"""Filter the tag containers based on search text."""
|
||||
if not hasattr(self, "tag_containers"):
|
||||
return
|
||||
|
||||
text = text.lower()
|
||||
|
||||
# Show/hide tag containers based on filter
|
||||
for tag, container in self.tag_containers.items():
|
||||
if not text or text in tag.lower():
|
||||
# Tag matches filter
|
||||
container.show()
|
||||
else:
|
||||
# Check if any device in this tag matches
|
||||
matches = False
|
||||
for device in self.device_tags.get(tag, []):
|
||||
if text in device.lower():
|
||||
matches = True
|
||||
break
|
||||
|
||||
container.setVisible(matches)
|
||||
|
||||
@SafeSlot()
|
||||
def add_devices_by_tag(self):
|
||||
"""Add devices with the selected tags to the active configuration."""
|
||||
# This would be implemented for drag-and-drop in the future
|
||||
|
||||
@SafeSlot()
|
||||
def remove_devices_by_tag(self):
|
||||
"""Remove devices with the selected tags from the active configuration."""
|
||||
# This would be implemented for drag-and-drop in the future
|
||||
|
||||
|
||||
class DeviceManager(BECWidget, QWidget):
|
||||
"""Widget to display the current device configuration in a table."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Main layout for the entire widget
|
||||
self.main_layout = QHBoxLayout(self)
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
# Create a splitter to hold the device tags widget and device table
|
||||
self.splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Create device tags widget
|
||||
self.device_tags_widget = DeviceTagsWidget(self)
|
||||
self.splitter.addWidget(self.device_tags_widget)
|
||||
|
||||
# Create container for device table and its controls
|
||||
self.table_container = QWidget()
|
||||
self.layout = QVBoxLayout(self.table_container)
|
||||
|
||||
# Create search bar
|
||||
self.search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText(
|
||||
"Filter devices (approximate matching)..."
|
||||
) # Default to fuzzy search
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.filter_devices)
|
||||
self.search_layout.addWidget(self.search_label)
|
||||
self.search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
self.fuzzy_toggle_layout = QHBoxLayout()
|
||||
self.fuzzy_toggle_label = QLabel("Exact Match:")
|
||||
self.fuzzy_toggle = ToggleSwitch()
|
||||
self.fuzzy_toggle.setChecked(False) # Default to fuzzy search (toggle OFF)
|
||||
self.fuzzy_toggle.stateChanged.connect(self.on_fuzzy_toggle_changed)
|
||||
self.fuzzy_toggle.setToolTip(
|
||||
"Toggle between approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_toggle_label.setToolTip(
|
||||
"Toggle between approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle_label)
|
||||
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle)
|
||||
self.fuzzy_toggle_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = 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_toggle_layout)
|
||||
|
||||
self.layout.addLayout(self.search_controls)
|
||||
|
||||
# Create table widget
|
||||
self.device_table = BECTable()
|
||||
self.device_table.setEditTriggers(QTableWidget.NoEditTriggers) # Make table read-only
|
||||
self.device_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.device_table.setAlternatingRowColors(True)
|
||||
self.layout.addWidget(self.device_table)
|
||||
|
||||
# Connect custom sorting handler
|
||||
self.device_table.horizontalHeader().sectionClicked.connect(self.handle_header_click)
|
||||
self.current_sort_section = 0
|
||||
self.current_sort_order = Qt.AscendingOrder
|
||||
|
||||
# Make table resizable
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.device_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Don't stretch the last section to prevent it from changing width
|
||||
self.device_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.device_table.verticalHeader().setVisible(False)
|
||||
|
||||
# Set up initial headers
|
||||
self.headers = [
|
||||
"Name",
|
||||
"Device Class",
|
||||
"Readout Priority",
|
||||
"Enabled",
|
||||
"Read Only",
|
||||
"Documentation",
|
||||
]
|
||||
self.device_table.setColumnCount(len(self.headers))
|
||||
self.device_table.setHorizontalHeaderLabels(self.headers)
|
||||
|
||||
# Set initial column resize modes
|
||||
header = self.device_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Name
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Device Class
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Readout Priority
|
||||
header.setSectionResizeMode(3, QHeaderView.Fixed) # Enabled
|
||||
header.setSectionResizeMode(4, QHeaderView.Fixed) # Read Only
|
||||
header.setSectionResizeMode(5, QHeaderView.Stretch) # Documentation
|
||||
|
||||
# Connect resize signal to adjust row heights when table is resized
|
||||
self.device_table.horizontalHeader().sectionResized.connect(self.on_table_resized)
|
||||
|
||||
# Set fixed width for checkbox columns
|
||||
self.device_table.setColumnWidth(3, 70) # Enabled column
|
||||
self.device_table.setColumnWidth(4, 70) # Read Only column
|
||||
|
||||
# Ensure column widths stay fixed
|
||||
header.setMinimumSectionSize(70)
|
||||
header.setDefaultSectionSize(100)
|
||||
|
||||
# Enable sorting by clicking column headers
|
||||
self.device_table.setSortingEnabled(False) # We'll handle sorting manually
|
||||
self.device_table.horizontalHeader().setSortIndicatorShown(True)
|
||||
self.device_table.horizontalHeader().setSectionsClickable(True)
|
||||
|
||||
# Add buttons for adding/removing devices
|
||||
self.button_layout = QHBoxLayout()
|
||||
|
||||
# Add device button
|
||||
self.add_device_button = QPushButton("Add Device")
|
||||
self.add_device_button.clicked.connect(self.add_device)
|
||||
self.button_layout.addWidget(self.add_device_button)
|
||||
|
||||
# Remove device button
|
||||
self.remove_device_button = QPushButton("Remove Device")
|
||||
self.remove_device_button.clicked.connect(self.remove_device)
|
||||
self.button_layout.addWidget(self.remove_device_button)
|
||||
|
||||
# Add buttons to main layout
|
||||
self.layout.addLayout(self.button_layout)
|
||||
|
||||
# Add the table container to the splitter
|
||||
self.splitter.addWidget(self.table_container)
|
||||
|
||||
# Set initial sizes (30% for tags, 70% for table)
|
||||
self.splitter.setSizes([300, 700])
|
||||
|
||||
# Add the splitter to the main layout
|
||||
self.main_layout.addWidget(self.splitter)
|
||||
|
||||
# Connect signals between widgets
|
||||
self.connect_signals()
|
||||
|
||||
# Load initial data
|
||||
self.update_device_table()
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals between the device table and tags widget."""
|
||||
# Connect add devices by tag button to update the table
|
||||
if hasattr(self.device_tags_widget, "add_tag_button"):
|
||||
self.device_tags_widget.add_tag_button.clicked.connect(self.update_device_table)
|
||||
|
||||
# Connect remove devices by tag button to update the table
|
||||
if hasattr(self.device_tags_widget, "remove_tag_button"):
|
||||
self.device_tags_widget.remove_tag_button.clicked.connect(self.update_device_table)
|
||||
|
||||
@SafeSlot(int, int, int)
|
||||
def on_table_resized(self, column, old_width, new_width):
|
||||
"""Handle table column resize events to readjust row heights for text wrapping."""
|
||||
# Only handle resizes of the documentation column
|
||||
if column == 5:
|
||||
# Update all rows with TextLabelWidgets in the documentation column
|
||||
for row in range(self.device_table.rowCount()):
|
||||
doc_widget = self.device_table.cellWidget(row, 5)
|
||||
if doc_widget and isinstance(doc_widget, TextLabelWidget):
|
||||
# Trigger recalculation of text wrapping
|
||||
doc_widget.updateGeometry()
|
||||
|
||||
# Force the table to recalculate row heights
|
||||
if doc_widget.value:
|
||||
# Get text metrics
|
||||
font_metrics = doc_widget.label.fontMetrics()
|
||||
|
||||
# Calculate new text height with word wrap
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
new_width - 10,
|
||||
2000, # New width constraint
|
||||
Qt.TextWordWrap,
|
||||
doc_widget.value,
|
||||
)
|
||||
|
||||
# Update row height
|
||||
row_height = text_rect.height() + 16
|
||||
self.device_table.setRowHeight(row, max(40, row_height))
|
||||
|
||||
@SafeSlot()
|
||||
def update_device_table(self):
|
||||
"""Update the device table with the current device configuration."""
|
||||
try:
|
||||
# Get device config (always a list of dictionaries)
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
|
||||
# Clear existing rows
|
||||
self.device_table.setRowCount(0)
|
||||
|
||||
# Add devices to the table
|
||||
for device_info in config:
|
||||
row_position = self.device_table.rowCount()
|
||||
self.device_table.insertRow(row_position)
|
||||
|
||||
# Set device name
|
||||
self.device_table.setItem(
|
||||
row_position, 0, SortableTableWidgetItem(device_info.get("name", "Unknown"))
|
||||
)
|
||||
|
||||
# Set device class
|
||||
device_class = device_info.get("deviceClass", "Unknown")
|
||||
self.device_table.setItem(row_position, 1, SortableTableWidgetItem(device_class))
|
||||
|
||||
# Set readout priority
|
||||
readout_priority = device_info.get("readoutPriority", "Unknown")
|
||||
self.device_table.setItem(
|
||||
row_position, 2, SortableTableWidgetItem(readout_priority)
|
||||
)
|
||||
|
||||
# Set enabled status as checkbox
|
||||
enabled_checkbox = CheckBoxCenterWidget(device_info.get("enabled", False))
|
||||
self.device_table.setCellWidget(row_position, 3, enabled_checkbox)
|
||||
|
||||
# Set read-only status as checkbox
|
||||
readonly_checkbox = CheckBoxCenterWidget(device_info.get("readOnly", False))
|
||||
self.device_table.setCellWidget(row_position, 4, readonly_checkbox)
|
||||
|
||||
# Set documentation using text label widget with word wrap
|
||||
documentation = device_info.get("documentation", "")
|
||||
doc_widget = TextLabelWidget(documentation)
|
||||
self.device_table.setCellWidget(row_position, 5, doc_widget)
|
||||
|
||||
# First, ensure the table is updated to show the new widgets
|
||||
self.device_table.viewport().update()
|
||||
|
||||
# Force a layout update to get proper sizes
|
||||
self.device_table.resizeRowsToContents()
|
||||
|
||||
# Then adjust row heights with better calculation for wrapped text
|
||||
for row in range(self.device_table.rowCount()):
|
||||
doc_widget = self.device_table.cellWidget(row, 5)
|
||||
if doc_widget and isinstance(doc_widget, TextLabelWidget):
|
||||
text = doc_widget.value
|
||||
if text:
|
||||
# Get the column width
|
||||
col_width = self.device_table.columnWidth(5)
|
||||
|
||||
# Calculate appropriate height for the text
|
||||
font_metrics = doc_widget.label.fontMetrics()
|
||||
|
||||
# Calculate text rectangle with word wrap
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
col_width - 10,
|
||||
2000, # Width constraint with large height
|
||||
Qt.TextWordWrap,
|
||||
text,
|
||||
)
|
||||
|
||||
# Set row height with additional padding
|
||||
row_height = text_rect.height() + 16
|
||||
self.device_table.setRowHeight(row, max(40, row_height))
|
||||
|
||||
# Update the widget to reflect the new size
|
||||
doc_widget.updateGeometry()
|
||||
|
||||
# Apply current sort if any
|
||||
if hasattr(self, "current_sort_section") and self.current_sort_section >= 0:
|
||||
self.sort_table(self.current_sort_section, self.current_sort_order)
|
||||
self.device_table.horizontalHeader().setSortIndicator(
|
||||
self.current_sort_section, self.current_sort_order
|
||||
)
|
||||
|
||||
# Reset the filter to make sure search works with new data
|
||||
if hasattr(self, "search_input"):
|
||||
current_filter = self.search_input.text()
|
||||
if current_filter:
|
||||
self.filter_devices(current_filter)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error updating device table: {str(e)}", self
|
||||
)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def on_fuzzy_toggle_changed(self, enabled):
|
||||
"""
|
||||
Handle exact match toggle state change.
|
||||
|
||||
When toggle is ON (enabled=True): Use exact matching
|
||||
When toggle is OFF (enabled=False): Use fuzzy/approximate matching
|
||||
"""
|
||||
# Update search mode label
|
||||
if hasattr(self, "search_input"):
|
||||
# Store original stylesheet to restore it later
|
||||
original_style = self.search_input.styleSheet()
|
||||
|
||||
# Set placeholder text based on mode
|
||||
if enabled: # Toggle ON = Exact match
|
||||
self.search_input.setPlaceholderText("Filter devices (exact match)...")
|
||||
print("Toggle switched ON: Using EXACT match mode")
|
||||
else: # Toggle OFF = Approximate/fuzzy match
|
||||
self.search_input.setPlaceholderText("Filter devices (approximate matching)...")
|
||||
print("Toggle switched OFF: Using FUZZY match mode")
|
||||
|
||||
# Visual feedback - briefly highlight the search box with appropriate color
|
||||
highlight_color = "#3498db" # Blue for feedback
|
||||
self.search_input.setStyleSheet(f"border: 2px solid {highlight_color};")
|
||||
|
||||
# Create a one-time timer to restore the original style after a short delay
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(500, lambda: self.search_input.setStyleSheet(original_style))
|
||||
|
||||
# Log the toggle state for debugging
|
||||
print(
|
||||
f"Search mode changed: Exact match = {enabled}, Toggle isChecked = {self.fuzzy_toggle.isChecked()}"
|
||||
)
|
||||
|
||||
# When toggle changes, reapply current search with new mode
|
||||
current_text = self.search_input.text()
|
||||
|
||||
# Always reapply the filter, even if text is empty
|
||||
# This ensures all rows are properly shown/hidden based on the new mode
|
||||
self.filter_devices(current_text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def filter_devices(self, text):
|
||||
"""Filter devices in the table based on exact or approximate matching."""
|
||||
# Always show all rows when search is empty, regardless of match mode
|
||||
if not text:
|
||||
for row in range(self.device_table.rowCount()):
|
||||
self.device_table.setRowHidden(row, False)
|
||||
return
|
||||
|
||||
# Get current search mode
|
||||
# When toggle is ON, we use exact match
|
||||
# When toggle is OFF, we use fuzzy/approximate match
|
||||
use_exact_match = hasattr(self, "fuzzy_toggle") and self.fuzzy_toggle.isChecked()
|
||||
|
||||
# Debug print to verify which mode is being used
|
||||
print(f"Filtering with exact match: {use_exact_match}, search text: '{text}'")
|
||||
|
||||
# Threshold for fuzzy matching (0-100, higher is more strict)
|
||||
threshold = 80
|
||||
|
||||
# Prepare search text (lowercase for case-insensitive search)
|
||||
search_text = text.lower()
|
||||
|
||||
# Count of matched rows for feedback (but avoid double-counting)
|
||||
visible_rows = 0
|
||||
total_rows = self.device_table.rowCount()
|
||||
|
||||
# Filter rows using either exact or approximate matching
|
||||
for row in range(total_rows):
|
||||
row_visible = False
|
||||
|
||||
# Check name and device class columns (0 and 1)
|
||||
for col in [0, 1]: # Name and Device Class columns
|
||||
item = self.device_table.item(row, col)
|
||||
if not item:
|
||||
continue
|
||||
|
||||
cell_text = item.text().lower()
|
||||
|
||||
if use_exact_match:
|
||||
# EXACT MATCH: Simple substring check
|
||||
if search_text in cell_text:
|
||||
row_visible = True
|
||||
break
|
||||
else:
|
||||
# FUZZY MATCH: Use approximate matching
|
||||
match_ratio = fuzz.partial_ratio(search_text, cell_text)
|
||||
if match_ratio >= threshold:
|
||||
row_visible = True
|
||||
break
|
||||
|
||||
# Hide or show this row
|
||||
self.device_table.setRowHidden(row, not row_visible)
|
||||
|
||||
# Count visible rows for potential feedback
|
||||
if row_visible:
|
||||
visible_rows += 1
|
||||
|
||||
@SafeSlot(int)
|
||||
def handle_header_click(self, section):
|
||||
"""Handle column header click to sort the table."""
|
||||
# Toggle sort order if clicking the same section
|
||||
if section == self.current_sort_section:
|
||||
self.current_sort_order = (
|
||||
Qt.DescendingOrder
|
||||
if self.current_sort_order == Qt.AscendingOrder
|
||||
else Qt.AscendingOrder
|
||||
)
|
||||
else:
|
||||
self.current_sort_section = section
|
||||
self.current_sort_order = Qt.AscendingOrder
|
||||
|
||||
# Update sort indicator
|
||||
self.device_table.horizontalHeader().setSortIndicator(
|
||||
self.current_sort_section, self.current_sort_order
|
||||
)
|
||||
|
||||
# Perform the sort
|
||||
self.sort_table(section, self.current_sort_order)
|
||||
|
||||
def sort_table(self, column, order):
|
||||
"""Sort the table by the specified column and order."""
|
||||
row_count = self.device_table.rowCount()
|
||||
if row_count <= 1:
|
||||
return # Nothing to sort
|
||||
|
||||
# Collect all rows for sorting
|
||||
rows_data = []
|
||||
for row in range(row_count):
|
||||
# Create a safe copy of the row data
|
||||
row_data = {}
|
||||
row_data["items"] = []
|
||||
row_data["widgets"] = []
|
||||
row_data["hidden"] = self.device_table.isRowHidden(row)
|
||||
row_data["sort_key"] = None
|
||||
|
||||
# Extract sort key for this row
|
||||
if column in [3, 4]: # Checkbox columns
|
||||
widget = self.device_table.cellWidget(row, column)
|
||||
if widget and hasattr(widget, "value"):
|
||||
row_data["sort_key"] = widget.value
|
||||
else:
|
||||
row_data["sort_key"] = False
|
||||
else: # Text columns
|
||||
item = self.device_table.item(row, column)
|
||||
if item:
|
||||
row_data["sort_key"] = item.text().lower()
|
||||
else:
|
||||
row_data["sort_key"] = ""
|
||||
|
||||
# Collect all items and widgets in the row
|
||||
for col in range(self.device_table.columnCount()):
|
||||
if col in [3, 4]: # Checkbox columns
|
||||
widget = self.device_table.cellWidget(row, col)
|
||||
if widget:
|
||||
# Store the widget value to recreate it
|
||||
is_checked = False
|
||||
if hasattr(widget, "value"):
|
||||
is_checked = widget.value
|
||||
elif hasattr(widget, "checkbox"):
|
||||
is_checked = widget.checkbox.isChecked()
|
||||
row_data["widgets"].append((col, "checkbox", is_checked))
|
||||
elif col == 5: # Documentation column with TextLabelWidget
|
||||
widget = self.device_table.cellWidget(row, col)
|
||||
if widget and isinstance(widget, TextLabelWidget):
|
||||
text = widget.value
|
||||
row_data["widgets"].append((col, "textlabel", text))
|
||||
else:
|
||||
row_data["widgets"].append((col, "textlabel", ""))
|
||||
else:
|
||||
item = self.device_table.item(row, col)
|
||||
if item:
|
||||
row_data["items"].append((col, item.text()))
|
||||
else:
|
||||
row_data["items"].append((col, ""))
|
||||
|
||||
rows_data.append(row_data)
|
||||
|
||||
# Sort the rows
|
||||
reverse = order == Qt.DescendingOrder
|
||||
sorted_rows = sorted(rows_data, key=lambda x: x["sort_key"], reverse=reverse)
|
||||
|
||||
# Rebuild the table with sorted data
|
||||
self.device_table.setUpdatesEnabled(False) # Disable updates while rebuilding
|
||||
|
||||
# Clear and rebuild the table
|
||||
self.device_table.clearContents()
|
||||
self.device_table.setRowCount(row_count)
|
||||
|
||||
for row, row_data in enumerate(sorted_rows):
|
||||
# Add text items
|
||||
for col, text in row_data["items"]:
|
||||
self.device_table.setItem(row, col, SortableTableWidgetItem(text))
|
||||
|
||||
# Add widgets
|
||||
for col, widget_type, value in row_data["widgets"]:
|
||||
if widget_type == "checkbox":
|
||||
checkbox = CheckBoxCenterWidget(value)
|
||||
self.device_table.setCellWidget(row, col, checkbox)
|
||||
elif widget_type == "textlabel":
|
||||
text_label = TextLabelWidget(value)
|
||||
self.device_table.setCellWidget(row, col, text_label)
|
||||
|
||||
# Restore hidden state
|
||||
self.device_table.setRowHidden(row, row_data["hidden"])
|
||||
|
||||
self.device_table.setUpdatesEnabled(True) # Re-enable updates
|
||||
|
||||
@SafeSlot()
|
||||
def show_add_device_dialog(self):
|
||||
"""Show the dialog for adding a new device."""
|
||||
# Call the add_device method to handle the dialog and logic
|
||||
self.add_device()
|
||||
|
||||
@SafeSlot()
|
||||
def add_device(self):
|
||||
"""Simulate adding a new device to the configuration."""
|
||||
try:
|
||||
# Create and show the add device dialog
|
||||
dialog = AddDeviceDialog(self)
|
||||
if dialog.exec():
|
||||
# Get device config from dialog
|
||||
device_config = dialog.get_device_config()
|
||||
device_name = device_config.get("name")
|
||||
|
||||
# Print the action that would be taken (simulation only)
|
||||
print(f"Would add device: {device_name} with config: {device_config}")
|
||||
|
||||
# Show simulation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Device Addition Simulated",
|
||||
f"Would add device: {device_name} (simulation only)",
|
||||
)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error in add device simulation: {str(e)}", self
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def remove_device(self):
|
||||
"""Simulate removing selected device(s) from the configuration."""
|
||||
selected_rows = self.device_table.selectionModel().selectedRows()
|
||||
|
||||
if not selected_rows:
|
||||
QMessageBox.information(self, "No Selection", "Please select a device to remove.")
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
device_count = len(selected_rows)
|
||||
message = f"Are you sure you want to remove {device_count} device{'s' if device_count > 1 else ''}?"
|
||||
confirmation = QMessageBox.question(
|
||||
self, "Confirm Removal", message, QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
if confirmation == QMessageBox.Yes:
|
||||
try:
|
||||
# Get device names from selected rows
|
||||
device_names = []
|
||||
for index in selected_rows:
|
||||
row = index.row()
|
||||
device_name = self.device_table.item(row, 0).text()
|
||||
device_names.append(device_name)
|
||||
|
||||
# Print removal action instead of actual removal
|
||||
print(f"Would remove devices: {device_names}")
|
||||
|
||||
# Show simulation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Device Removal Simulated",
|
||||
f"Would remove {device_count} device{'s' if device_count > 1 else ''} (simulation only)",
|
||||
)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error in remove device simulation: {str(e)}", self
|
||||
)
|
||||
|
||||
|
||||
class AddDeviceDialog(QDialog):
|
||||
"""Dialog for adding a new device to the configuration."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Add New Device")
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
# Create layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.form_layout = QFormLayout()
|
||||
|
||||
# Device name
|
||||
self.name_input = QLineEdit()
|
||||
self.form_layout.addRow("Device Name:", self.name_input)
|
||||
|
||||
# Device class
|
||||
self.device_class_input = QLineEdit()
|
||||
self.device_class_input.setText("ophyd_devices.SimPositioner")
|
||||
self.form_layout.addRow("Device Class:", self.device_class_input)
|
||||
|
||||
# Readout priority
|
||||
self.readout_priority_combo = QComboBox()
|
||||
self.readout_priority_combo.addItems(["baseline", "monitored", "async", "on_request"])
|
||||
self.form_layout.addRow("Readout Priority:", self.readout_priority_combo)
|
||||
|
||||
# Enabled checkbox
|
||||
self.enabled_checkbox = QCheckBox()
|
||||
self.enabled_checkbox.setChecked(True)
|
||||
self.form_layout.addRow("Enabled:", self.enabled_checkbox)
|
||||
|
||||
# Read-only checkbox
|
||||
self.readonly_checkbox = QCheckBox()
|
||||
self.form_layout.addRow("Read Only:", self.readonly_checkbox)
|
||||
|
||||
# Documentation text
|
||||
self.documentation_input = QLineEdit()
|
||||
self.form_layout.addRow("Documentation:", self.documentation_input)
|
||||
|
||||
# Add form to layout
|
||||
self.layout.addLayout(self.form_layout)
|
||||
|
||||
# Add buttons
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.cancel_button = QPushButton("Cancel")
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.add_button = QPushButton("Add Device")
|
||||
self.add_button.clicked.connect(self.accept)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
self.button_layout.addWidget(self.add_button)
|
||||
|
||||
self.layout.addLayout(self.button_layout)
|
||||
|
||||
def get_device_config(self):
|
||||
"""Get the device configuration from the dialog."""
|
||||
return {
|
||||
"name": self.name_input.text(),
|
||||
"deviceClass": self.device_class_input.text(),
|
||||
"readoutPriority": self.readout_priority_combo.currentText(),
|
||||
"enabled": self.enabled_checkbox.isChecked(),
|
||||
"readOnly": self.readonly_checkbox.isChecked(),
|
||||
"documentation": self.documentation_input.text(),
|
||||
"deviceConfig": {}, # Empty config for now
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
window = DeviceManager()
|
||||
window.show()
|
||||
app.exec_()
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Literal, Sequence
|
||||
from typing import Literal
|
||||
|
||||
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 = "dict" # Used when the type is provided as a dict with Literal key
|
||||
LITERALS = "dict"
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
@@ -83,39 +83,6 @@ 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
|
||||
@@ -170,7 +137,7 @@ class ScanGroupBox(QGroupBox):
|
||||
ScanArgType.INT: ScanSpinBox,
|
||||
ScanArgType.BOOL: ScanCheckBox,
|
||||
ScanArgType.STR: ScanLineEdit,
|
||||
ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
|
||||
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
|
||||
}
|
||||
|
||||
device_selected = Signal(str)
|
||||
@@ -259,11 +226,7 @@ class ScanGroupBox(QGroupBox):
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
arg_name = item.get("name", None)
|
||||
default = item.get("default", 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)
|
||||
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']}'"
|
||||
@@ -276,8 +239,6 @@ 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"])
|
||||
@@ -375,8 +336,6 @@ 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
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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_()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['ide_explorer.py']}
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,15 +0,0 @@
|
||||
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()
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.37.0"
|
||||
version = "2.35.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
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"))
|
||||
@@ -210,15 +210,6 @@ 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -313,10 +304,7 @@ 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()
|
||||
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)
|
||||
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
|
||||
assert isinstance(widget, expected_widget_type)
|
||||
|
||||
|
||||
@@ -453,7 +441,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": ""}, "optim_trajectory": None}
|
||||
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
|
||||
|
||||
# Check the emitted signal
|
||||
mock_slot.assert_called_once()
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user