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

Compare commits

..

1 Commits

Author SHA1 Message Date
fde7b4db6c scratch - device manager 2025-08-18 09:59:54 +02:00
16 changed files with 1005 additions and 1271 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View 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_()

View File

@@ -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

View File

@@ -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_()

View File

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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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"))

View File

@@ -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()

View File

@@ -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"