Coverage for openhcs/pyqt_gui/windows/function_selector_window.py: 0.0%
226 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Function Selector Window for PyQt6
4Function selection dialog with search and filtering capabilities.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9from typing import Optional, Callable, List, Dict, Any
10import inspect
12from PyQt6.QtWidgets import (
13 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
14 QTreeWidget, QTreeWidgetItem, QLineEdit, QTextEdit, QFrame,
15 QSplitter, QGroupBox, QWidget
16)
17from PyQt6.QtCore import Qt, pyqtSignal
18from PyQt6.QtGui import QFont
20from openhcs.textual_tui.services.function_registry_service import FunctionRegistryService
21from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
24logger = logging.getLogger(__name__)
27class FunctionSelectorWindow(QDialog):
28 """
29 PyQt6 Function Selector Window.
31 Function selection dialog with search, filtering, and preview capabilities.
32 Preserves all business logic from Textual version with clean PyQt6 UI.
33 """
35 # Signals
36 function_selected = pyqtSignal(object) # Selected function
37 selection_cancelled = pyqtSignal()
39 def __init__(self, on_result_callback: Optional[Callable] = None,
40 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
41 """
42 Initialize the function selector window.
44 Args:
45 on_result_callback: Callback for function selection result
46 color_scheme: Color scheme for styling (optional, uses default if None)
47 parent: Parent widget
48 """
49 super().__init__(parent)
51 # Business logic state (extracted from Textual version)
52 self.on_result_callback = on_result_callback
53 self.function_registry = FunctionRegistryService()
55 # Initialize color scheme and style generator
56 self.color_scheme = color_scheme or PyQt6ColorScheme()
57 self.style_generator = StyleSheetGenerator(self.color_scheme)
59 # Current state
60 self.available_functions: Dict[str, Any] = {}
61 self.filtered_functions: Dict[str, Any] = {}
62 self.selected_function = None
63 self.search_text = ""
65 # UI components
66 self.search_edit: Optional[QLineEdit] = None
67 self.function_tree: Optional[QTreeWidget] = None
68 self.preview_text: Optional[QTextEdit] = None
70 # Setup UI
71 self.setup_ui()
72 self.setup_connections()
73 self.load_functions()
75 logger.debug("Function selector window initialized")
77 def setup_ui(self):
78 """Setup the user interface."""
79 self.setWindowTitle("Select Function")
80 self.setModal(True)
81 self.setMinimumSize(800, 600)
82 self.resize(1000, 700)
84 layout = QVBoxLayout(self)
85 layout.setSpacing(10)
87 # Header
88 header_label = QLabel("Function Library")
89 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
90 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; padding: 10px;")
91 layout.addWidget(header_label)
93 # Search section
94 search_frame = self.create_search_section()
95 layout.addWidget(search_frame)
97 # Main content area
98 content_splitter = self.create_content_area()
99 layout.addWidget(content_splitter)
101 # Button panel
102 button_panel = self.create_button_panel()
103 layout.addWidget(button_panel)
105 # Apply centralized styling
106 self.setStyleSheet(self.style_generator.generate_dialog_style() + "\n" +
107 self.style_generator.generate_tree_widget_style())
109 def create_search_section(self) -> QWidget:
110 """
111 Create the search section.
113 Returns:
114 Widget containing search controls
115 """
116 frame = QFrame()
117 frame.setFrameStyle(QFrame.Shape.Box)
118 frame.setStyleSheet(f"""
119 QFrame {{
120 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
121 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
122 border-radius: 3px;
123 padding: 8px;
124 }}
125 """)
127 layout = QHBoxLayout(frame)
129 # Search label
130 search_label = QLabel("Search:")
131 search_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: bold;")
132 layout.addWidget(search_label)
134 # Search edit
135 self.search_edit = QLineEdit()
136 self.search_edit.setPlaceholderText("Search functions by name, module, or description...")
137 self.search_edit.textChanged.connect(self.on_search_changed)
138 layout.addWidget(self.search_edit)
140 # Clear button
141 clear_button = QPushButton("Clear")
142 clear_button.setMaximumWidth(60)
143 clear_button.clicked.connect(self.clear_search)
144 clear_button.setStyleSheet(f"""
145 QPushButton {{
146 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
147 color: white;
148 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
149 border-radius: 3px;
150 padding: 5px;
151 }}
152 QPushButton:hover {{
153 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
154 }}
155 """)
156 layout.addWidget(clear_button)
158 return frame
160 def create_content_area(self) -> QWidget:
161 """
162 Create the main content area.
164 Returns:
165 Widget containing function tree and preview
166 """
167 splitter = QSplitter(Qt.Orientation.Horizontal)
169 # Function tree
170 function_group = QGroupBox("Available Functions")
171 function_group.setStyleSheet(f"""
172 QGroupBox {{
173 font-weight: bold;
174 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
175 border-radius: 5px;
176 margin-top: 10px;
177 padding-top: 10px;
178 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
179 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
180 }}
181 QGroupBox::title {{
182 subcontrol-origin: margin;
183 left: 10px;
184 padding: 0 5px 0 5px;
185 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
186 }}
187 """)
189 function_layout = QVBoxLayout(function_group)
191 self.function_tree = QTreeWidget()
192 self.function_tree.setHeaderLabels(["Function", "Module"])
193 self.function_tree.setRootIsDecorated(True)
194 self.function_tree.setSortingEnabled(True)
195 self.function_tree.sortByColumn(0, Qt.SortOrder.AscendingOrder)
196 function_layout.addWidget(self.function_tree)
198 splitter.addWidget(function_group)
200 # Preview panel
201 preview_group = QGroupBox("Function Preview")
202 preview_group.setStyleSheet(f"""
203 QGroupBox {{
204 font-weight: bold;
205 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
206 border-radius: 5px;
207 margin-top: 10px;
208 padding-top: 10px;
209 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
210 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
211 }}
212 QGroupBox::title {{
213 subcontrol-origin: margin;
214 left: 10px;
215 padding: 0 5px 0 5px;
216 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
217 }}
218 """)
220 preview_layout = QVBoxLayout(preview_group)
222 self.preview_text = QTextEdit()
223 self.preview_text.setReadOnly(True)
224 self.preview_text.setFont(QFont("Courier New", 10))
225 self.preview_text.setPlaceholderText("Select a function to see its details...")
226 preview_layout.addWidget(self.preview_text)
228 splitter.addWidget(preview_group)
230 # Set splitter proportions
231 splitter.setSizes([500, 400])
233 return splitter
235 def create_button_panel(self) -> QWidget:
236 """
237 Create the button panel.
239 Returns:
240 Widget containing action buttons
241 """
242 panel = QFrame()
243 panel.setFrameStyle(QFrame.Shape.Box)
244 panel.setStyleSheet(f"""
245 QFrame {{
246 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
247 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
248 border-radius: 3px;
249 padding: 10px;
250 }}
251 """)
253 layout = QHBoxLayout(panel)
255 # Function count label
256 self.count_label = QLabel("")
257 self.count_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)}; font-style: italic;")
258 layout.addWidget(self.count_label)
260 layout.addStretch()
262 # Cancel button
263 cancel_button = QPushButton("Cancel")
264 cancel_button.setMinimumWidth(80)
265 cancel_button.clicked.connect(self.cancel_selection)
266 cancel_button.setStyleSheet(f"""
267 QPushButton {{
268 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
269 color: white;
270 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)};
271 border-radius: 3px;
272 padding: 8px;
273 }}
274 QPushButton:hover {{
275 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
276 }}
277 """)
278 layout.addWidget(cancel_button)
280 # Select button
281 self.select_button = QPushButton("Select")
282 self.select_button.setMinimumWidth(80)
283 self.select_button.setEnabled(False)
284 self.select_button.clicked.connect(self.confirm_selection)
285 self.select_button.setStyleSheet(f"""
286 QPushButton {{
287 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
288 color: white;
289 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
290 border-radius: 3px;
291 padding: 8px;
292 }}
293 QPushButton:hover {{
294 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
295 }}
296 QPushButton:disabled {{
297 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
298 color: {self.color_scheme.to_hex(self.color_scheme.border_light)};
299 }}
300 """)
301 layout.addWidget(self.select_button)
303 return panel
305 def setup_connections(self):
306 """Setup signal/slot connections."""
307 if self.function_tree:
308 self.function_tree.itemSelectionChanged.connect(self.on_selection_changed)
309 self.function_tree.itemDoubleClicked.connect(self.on_item_double_clicked)
311 def load_functions(self):
312 """Load available functions from the registry."""
313 try:
314 # Get functions from registry (extracted from Textual version)
315 self.available_functions = self.function_registry.get_all_functions()
316 self.filtered_functions = self.available_functions.copy()
318 self.update_function_tree()
319 self.update_count_label()
321 logger.debug(f"Loaded {len(self.available_functions)} functions")
323 except Exception as e:
324 logger.error(f"Failed to load functions: {e}")
325 self.available_functions = {}
326 self.filtered_functions = {}
328 def update_function_tree(self):
329 """Update the function tree display."""
330 self.function_tree.clear()
332 # Group functions by module
333 modules = {}
334 for func_name, func_info in self.filtered_functions.items():
335 module_name = getattr(func_info.get('function'), '__module__', 'Unknown')
336 if module_name not in modules:
337 modules[module_name] = []
338 modules[module_name].append((func_name, func_info))
340 # Create tree items
341 for module_name, functions in sorted(modules.items()):
342 module_item = QTreeWidgetItem(self.function_tree)
343 module_item.setText(0, module_name)
344 module_item.setText(1, f"({len(functions)} functions)")
345 module_item.setExpanded(True)
347 # Add function items
348 for func_name, func_info in sorted(functions):
349 func_item = QTreeWidgetItem(module_item)
350 func_item.setText(0, func_name)
351 func_item.setText(1, module_name)
352 func_item.setData(0, Qt.ItemDataRole.UserRole, func_info)
354 # Add icon or styling based on function type
355 func_item.setToolTip(0, func_info['function'].__doc__ or "No description available")
357 def update_count_label(self):
358 """Update the function count label."""
359 total = len(self.available_functions)
360 filtered = len(self.filtered_functions)
362 if filtered == total:
363 self.count_label.setText(f"{total} functions")
364 else:
365 self.count_label.setText(f"{filtered} of {total} functions")
367 def on_search_changed(self, text: str):
368 """Handle search text changes."""
369 self.search_text = text.lower().strip()
370 self.filter_functions()
372 def filter_functions(self):
373 """Filter functions based on search text."""
374 if not self.search_text:
375 self.filtered_functions = self.available_functions.copy()
376 else:
377 self.filtered_functions = {}
379 for func_name, func_info in self.available_functions.items():
380 # Search in function name
381 if self.search_text in func_name.lower():
382 self.filtered_functions[func_name] = func_info
383 continue
385 # Search in module name
386 module_name = getattr(func_info.get('function'), '__module__', '')
387 if self.search_text in module_name.lower():
388 self.filtered_functions[func_name] = func_info
389 continue
391 # Search in docstring
392 docstring = getattr(func_info.get('function'), '__doc__', '') or ''
393 if self.search_text in docstring.lower():
394 self.filtered_functions[func_name] = func_info
395 continue
397 self.update_function_tree()
398 self.update_count_label()
400 def clear_search(self):
401 """Clear the search text."""
402 self.search_edit.clear()
404 def on_selection_changed(self):
405 """Handle function tree selection changes."""
406 selected_items = self.function_tree.selectedItems()
408 if selected_items:
409 item = selected_items[0]
410 func_info = item.data(0, Qt.ItemDataRole.UserRole)
412 if func_info: # Function item selected
413 self.selected_function = func_info['function']
414 self.update_preview(func_info)
415 self.select_button.setEnabled(True)
416 else: # Module item selected
417 self.selected_function = None
418 self.preview_text.clear()
419 self.select_button.setEnabled(False)
420 else:
421 self.selected_function = None
422 self.preview_text.clear()
423 self.select_button.setEnabled(False)
425 def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
426 """Handle double-click on function items."""
427 func_info = item.data(0, Qt.ItemDataRole.UserRole)
428 if func_info: # Function item double-clicked
429 self.selected_function = func_info['function']
430 self.confirm_selection()
432 def update_preview(self, func_info: Dict[str, Any]):
433 """
434 Update the function preview.
436 Args:
437 func_info: Function information dictionary
438 """
439 func = func_info['function']
441 preview_text = f"Function: {func.__name__}\n"
442 preview_text += f"Module: {func.__module__}\n\n"
444 # Function signature
445 try:
446 sig = inspect.signature(func)
447 preview_text += f"Signature:\n{func.__name__}{sig}\n\n"
448 except Exception:
449 preview_text += "Signature: Not available\n\n"
451 # Docstring
452 docstring = func.__doc__
453 if docstring:
454 preview_text += f"Description:\n{docstring.strip()}\n\n"
455 else:
456 preview_text += "Description: No documentation available\n\n"
458 # Additional info
459 if func.__file__:
460 preview_text += f"Source: {func.__file__}\n"
462 self.preview_text.setPlainText(preview_text)
464 def confirm_selection(self):
465 """Confirm the function selection."""
466 if not self.selected_function:
467 from PyQt6.QtWidgets import QMessageBox
468 QMessageBox.warning(self, "No Selection", "Please select a function.")
469 return
471 # Emit signal and call callback
472 self.function_selected.emit(self.selected_function)
474 if self.on_result_callback:
475 self.on_result_callback(self.selected_function)
477 self.accept()
478 logger.debug(f"Function selected: {self.selected_function.__name__}")
480 def cancel_selection(self):
481 """Cancel the function selection."""
482 self.selection_cancelled.emit()
483 self.reject()
484 logger.debug("Function selection cancelled")
487# Convenience function for opening function selector
488def open_function_selector_window(on_result_callback: Optional[Callable] = None, parent=None):
489 """
490 Open function selector window.
492 Args:
493 on_result_callback: Callback for function selection result
494 parent: Parent widget
496 Returns:
497 Dialog result
498 """
499 selector = FunctionSelectorWindow(on_result_callback, parent)
500 return selector.exec()