Coverage for openhcs/pyqt_gui/widgets/function_pane.py: 0.0%
260 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""
2Function Pane Widget for PyQt6
4Individual function display with parameter editing capabilities.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9from typing import Any, Dict, Callable, Optional, Tuple
10from pathlib import Path
12from PyQt6.QtWidgets import (
13 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
14 QFrame, QScrollArea, QGroupBox, QFormLayout
15)
16from PyQt6.QtCore import Qt, pyqtSignal
17from PyQt6.QtGui import QFont
19from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager
20from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager
21from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer
23# Import PyQt6 help components (using same pattern as Textual TUI)
24from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator
25from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
27logger = logging.getLogger(__name__)
30class FunctionPaneWidget(QWidget):
31 """
32 PyQt6 Function Pane Widget.
34 Displays individual function with editable parameters and control buttons.
35 Preserves all business logic from Textual version with clean PyQt6 UI.
36 """
38 # Signals
39 parameter_changed = pyqtSignal(int, str, object) # index, param_name, value
40 function_changed = pyqtSignal(int) # index
41 add_function = pyqtSignal(int) # index
42 remove_function = pyqtSignal(int) # index
43 move_function = pyqtSignal(int, int) # index, direction
44 reset_parameters = pyqtSignal(int) # index
46 def __init__(self, func_item: Tuple[Callable, Dict], index: int, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
47 """
48 Initialize the function pane widget.
50 Args:
51 func_item: Tuple of (function, kwargs)
52 index: Function index in the list
53 service_adapter: PyQt service adapter for dialogs and operations
54 parent: Parent widget
55 """
56 super().__init__(parent)
58 # Initialize color scheme
59 self.color_scheme = color_scheme or PyQt6ColorScheme()
61 # Core dependencies
62 self.service_adapter = service_adapter
64 # Business logic state (extracted from Textual version)
65 self.func, self.kwargs = func_item
66 self.index = index
67 self.show_parameters = True
69 # Parameter management (extracted from Textual version)
70 if self.func:
71 param_info = SignatureAnalyzer.analyze(self.func)
72 print(f"🔍 FUNCTION PANE DEBUG: param_info = {param_info}")
73 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()}
74 parameter_types = {name: info.param_type for name, info in param_info.items()}
76 # Store function signature defaults
77 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
78 print(f"🔍 FUNCTION PANE DEBUG: param_defaults = {self.param_defaults}")
80 # SIMPLIFIED: Use new generic constructor with function as object_instance
81 self.form_manager = ParameterFormManager(
82 object_instance=self.func, # Pass function as the object to build form for
83 field_id=f"func_{index}", # Use function index as field identifier
84 parent=self, # Pass self as parent widget
85 context_obj=None # Functions don't need context for placeholder resolution
86 )
87 else:
88 self.form_manager = None
89 self.param_defaults = {}
91 # Internal kwargs tracking (extracted from Textual version)
92 self._internal_kwargs = self.kwargs.copy()
94 # UI components
95 self.parameter_widgets: Dict[str, QWidget] = {}
97 # Setup UI
98 self.setup_ui()
99 self.setup_connections()
101 logger.debug(f"Function pane widget initialized for index {index}")
103 def setup_ui(self):
104 """Setup the user interface."""
105 layout = QVBoxLayout(self)
106 layout.setContentsMargins(5, 5, 5, 5)
107 layout.setSpacing(5)
109 # Function header
110 header_frame = self.create_function_header()
111 layout.addWidget(header_frame)
113 # Control buttons
114 button_frame = self.create_button_panel()
115 layout.addWidget(button_frame)
117 # Parameter form (if function exists and parameters shown)
118 if self.func and self.show_parameters and self.form_manager:
119 parameter_frame = self.create_parameter_form()
120 layout.addWidget(parameter_frame)
122 # Set styling
123 self.setStyleSheet(f"""
124 FunctionPaneWidget {{
125 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
126 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
127 border-radius: 5px;
128 margin: 2px;
129 }}
130 """)
132 def create_function_header(self) -> QWidget:
133 """
134 Create the function header with name and info.
136 Returns:
137 Widget containing function header
138 """
139 frame = QFrame()
140 frame.setFrameStyle(QFrame.Shape.Box)
141 frame.setStyleSheet(f"""
142 QFrame {{
143 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
144 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.separator_color)};
145 border-radius: 3px;
146 padding: 5px;
147 }}
148 """)
150 layout = QHBoxLayout(frame)
152 # Function name with help functionality (reuses Textual TUI help logic)
153 if self.func:
154 func_name = self.func.__name__
155 func_module = self.func.__module__
157 # Function name with help
158 name_label = QLabel(f"🔧 {func_name}")
159 name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold))
160 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
161 layout.addWidget(name_label)
163 # Help indicator for function (import locally to avoid circular imports)
164 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator
165 help_indicator = HelpIndicator(help_target=self.func, color_scheme=self.color_scheme)
166 layout.addWidget(help_indicator)
168 # Module info
169 if func_module:
170 module_label = QLabel(f"({func_module})")
171 module_label.setFont(QFont("Arial", 8))
172 module_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};")
173 layout.addWidget(module_label)
174 else:
175 name_label = QLabel("No Function Selected")
176 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_error)};")
177 layout.addWidget(name_label)
179 layout.addStretch()
181 return frame
183 def create_button_panel(self) -> QWidget:
184 """
185 Create the control button panel.
187 Returns:
188 Widget containing control buttons
189 """
190 frame = QFrame()
191 frame.setFrameStyle(QFrame.Shape.Box)
192 frame.setStyleSheet(f"""
193 QFrame {{
194 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
195 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
196 border-radius: 3px;
197 padding: 3px;
198 }}
199 """)
201 layout = QHBoxLayout(frame)
202 layout.addStretch() # Center the buttons
204 # Button configurations (extracted from Textual version)
205 button_configs = [
206 ("↑", "move_up", "Move function up"),
207 ("↓", "move_down", "Move function down"),
208 ("Add", "add_func", "Add new function"),
209 ("Delete", "remove_func", "Delete this function"),
210 ("Reset", "reset_all", "Reset all parameters"),
211 ]
213 for name, action, tooltip in button_configs:
214 button = QPushButton(name)
215 button.setToolTip(tooltip)
216 button.setMaximumWidth(60)
217 button.setMaximumHeight(25)
218 button.setStyleSheet(f"""
219 QPushButton {{
220 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
221 color: white;
222 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
223 border-radius: 2px;
224 padding: 2px;
225 font-size: 10px;
226 }}
227 QPushButton:hover {{
228 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
229 }}
230 QPushButton:pressed {{
231 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)};
232 }}
233 """)
235 # Connect button to action
236 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a))
238 layout.addWidget(button)
240 layout.addStretch() # Center the buttons
242 return frame
244 def create_parameter_form(self) -> QWidget:
245 """
246 Create the parameter form using extracted business logic.
248 Returns:
249 Widget containing parameter form
250 """
251 group_box = QGroupBox("Parameters")
252 group_box.setStyleSheet(f"""
253 QGroupBox {{
254 font-weight: bold;
255 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
256 border-radius: 3px;
257 margin-top: 10px;
258 padding-top: 10px;
259 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
260 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
261 }}
262 QGroupBox::title {{
263 subcontrol-origin: margin;
264 left: 10px;
265 padding: 0 5px 0 5px;
266 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
267 }}
268 """)
270 layout = QVBoxLayout(group_box)
272 # Use the enhanced ParameterFormManager that has help and reset functionality
273 if self.form_manager:
274 # Import the enhanced PyQt6 ParameterFormManager
275 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager as PyQtParameterFormManager
277 # FIXED: Use new simplified constructor
278 enhanced_form_manager = PyQtParameterFormManager(
279 object_instance=self.func, # Pass function as the object to build form for
280 field_id=f"func_{self.index}", # Use function index as field identifier
281 parent=self, # Pass self as parent widget
282 context_obj=None # Functions don't need context for placeholder resolution
283 )
285 # Connect parameter changes
286 enhanced_form_manager.parameter_changed.connect(
287 lambda param_name, value: self.handle_parameter_change(param_name, value)
288 )
290 layout.addWidget(enhanced_form_manager)
292 # Store reference for parameter updates
293 self.enhanced_form_manager = enhanced_form_manager
295 return group_box
297 def create_parameter_widget(self, param_name: str, param_type: type, current_value: Any) -> Optional[QWidget]:
298 """
299 Create parameter widget based on type.
301 Args:
302 param_name: Parameter name
303 param_type: Parameter type
304 current_value: Current parameter value
306 Returns:
307 Widget for parameter editing or None
308 """
309 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QComboBox
310 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
311 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
312 )
314 # Boolean parameters
315 if param_type == bool:
316 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
317 widget = NoneAwareCheckBox()
318 widget.set_value(current_value) # Use set_value to handle None properly
319 widget.toggled.connect(lambda checked: self.handle_parameter_change(param_name, checked))
320 return widget
322 # Integer parameters
323 elif param_type == int:
324 widget = NoScrollSpinBox()
325 widget.setRange(-999999, 999999)
326 widget.setValue(int(current_value) if current_value is not None else 0)
327 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
328 return widget
330 # Float parameters
331 elif param_type == float:
332 widget = NoScrollDoubleSpinBox()
333 widget.setRange(-999999.0, 999999.0)
334 widget.setDecimals(6)
335 widget.setValue(float(current_value) if current_value is not None else 0.0)
336 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
337 return widget
339 # Enum parameters
340 elif any(base.__name__ == 'Enum' for base in param_type.__bases__):
341 from openhcs.pyqt_gui.widgets.shared.widget_strategies import create_enum_widget_unified
343 # Use the single source of truth for enum widget creation
344 widget = create_enum_widget_unified(param_type, current_value)
346 widget.currentIndexChanged.connect(
347 lambda index: self.handle_parameter_change(param_name, widget.itemData(index))
348 )
349 return widget
351 # String and other parameters
352 else:
353 widget = QLineEdit()
354 widget.setText(str(current_value) if current_value is not None else "")
355 widget.textChanged.connect(lambda text: self.handle_parameter_change(param_name, text))
356 return widget
358 def setup_connections(self):
359 """Setup signal/slot connections."""
360 pass # Connections are set up in widget creation
362 def handle_button_action(self, action: str):
363 """
364 Handle button actions (extracted from Textual version).
366 Args:
367 action: Action identifier
368 """
369 if action == "move_up":
370 self.move_function.emit(self.index, -1)
371 elif action == "move_down":
372 self.move_function.emit(self.index, 1)
373 elif action == "add_func":
374 self.add_function.emit(self.index + 1)
375 elif action == "remove_func":
376 self.remove_function.emit(self.index)
377 elif action == "reset_all":
378 self.reset_all_parameters()
380 def handle_parameter_change(self, param_name: str, value: Any):
381 """
382 Handle parameter value changes (extracted from Textual version).
384 Args:
385 param_name: Name of the parameter
386 value: New parameter value
387 """
388 # Update internal kwargs without triggering reactive update
389 self._internal_kwargs[param_name] = value
391 # Update form manager
392 if self.form_manager:
393 self.form_manager.update_parameter(param_name, value)
394 final_value = self.form_manager.parameters[param_name]
395 else:
396 final_value = value
398 # Emit parameter changed signal
399 self.parameter_changed.emit(self.index, param_name, final_value)
401 logger.debug(f"Parameter changed: {param_name} = {final_value}")
403 def reset_all_parameters(self):
404 """Reset all parameters to default values using enhanced PyQt6 form manager."""
406 # Use the PyQt6 form manager's reset functionality which properly handles widgets
407 self.enhanced_form_manager.reset_all_parameters()
409 # Update internal kwargs to match the reset values
410 for param_name, default_value in self.param_defaults.items():
411 self._internal_kwargs[param_name] = default_value
412 # Emit parameter changed signal
413 self.parameter_changed.emit(self.index, param_name, default_value)
415 self.reset_parameters.emit(self.index)
416 logger.debug(f"Reset all parameters for function {self.index}")
418 def update_widget_value(self, widget: QWidget, value: Any):
419 """
420 Update widget value without triggering signals.
422 Args:
423 widget: Widget to update
424 value: New value
425 """
426 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox
427 # Import the no-scroll classes from single source of truth
428 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
429 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
430 )
432 # Temporarily block signals to avoid recursion
433 widget.blockSignals(True)
435 try:
436 if isinstance(widget, QCheckBox):
437 widget.setChecked(bool(value))
438 elif isinstance(widget, (QSpinBox, NoScrollSpinBox)):
439 widget.setValue(int(value) if value is not None else 0)
440 elif isinstance(widget, (QDoubleSpinBox, NoScrollDoubleSpinBox)):
441 widget.setValue(float(value) if value is not None else 0.0)
442 elif isinstance(widget, (QComboBox, NoScrollComboBox)):
443 for i in range(widget.count()):
444 if widget.itemData(i) == value:
445 widget.setCurrentIndex(i)
446 break
447 elif isinstance(widget, QLineEdit):
448 widget.setText(str(value) if value is not None else "")
449 finally:
450 widget.blockSignals(False)
452 def get_current_kwargs(self) -> Dict[str, Any]:
453 """
454 Get current kwargs values (extracted from Textual version).
456 Returns:
457 Current parameter values
458 """
459 return self._internal_kwargs.copy()
461 def sync_kwargs(self):
462 """Sync internal kwargs to main kwargs (extracted from Textual version)."""
463 self.kwargs = self._internal_kwargs.copy()
465 def update_function(self, func_item: Tuple[Callable, Dict]):
466 """
467 Update the function and parameters.
469 Args:
470 func_item: New function item tuple
471 """
472 self.func, self.kwargs = func_item
473 self._internal_kwargs = self.kwargs.copy()
475 # Recreate form manager
476 if self.func:
477 param_info = SignatureAnalyzer.analyze(self.func)
478 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()}
479 parameter_types = {name: info.param_type for name, info in param_info.items()}
481 # Store function signature defaults
482 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
484 # SIMPLIFIED: Use new generic constructor with function as object_instance
485 self.form_manager = ParameterFormManager(
486 object_instance=self.func, # Pass function as the object to build form for
487 field_id=f"func_{self.index}", # Use function index as field identifier
488 parent=self, # Pass self as parent widget
489 context_obj=None # Functions don't need context for placeholder resolution
490 )
491 else:
492 self.form_manager = None
493 self.param_defaults = {}
495 # Rebuild UI
496 self.setup_ui()
498 logger.debug(f"Updated function for index {self.index}")
501class FunctionListWidget(QWidget):
502 """
503 PyQt6 Function List Widget.
505 Container for multiple FunctionPaneWidgets with list management.
506 """
508 # Signals
509 functions_changed = pyqtSignal(list) # List of function items
511 def __init__(self, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
512 """
513 Initialize the function list widget.
515 Args:
516 service_adapter: PyQt service adapter
517 parent: Parent widget
518 """
519 super().__init__(parent)
521 # Initialize color scheme
522 self.color_scheme = color_scheme or PyQt6ColorScheme()
524 self.service_adapter = service_adapter
525 self.functions: List[Tuple[Callable, Dict]] = []
526 self.function_panes: List[FunctionPaneWidget] = []
528 # Setup UI
529 self.setup_ui()
531 def setup_ui(self):
532 """Setup the user interface."""
533 layout = QVBoxLayout(self)
535 # Scroll area for function panes
536 scroll_area = QScrollArea()
537 scroll_area.setWidgetResizable(True)
538 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
539 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
541 # Container widget for function panes
542 self.container_widget = QWidget()
543 self.container_layout = QVBoxLayout(self.container_widget)
544 self.container_layout.setSpacing(5)
546 scroll_area.setWidget(self.container_widget)
547 layout.addWidget(scroll_area)
549 # Add function button
550 add_button = QPushButton("Add Function")
551 add_button.clicked.connect(lambda: self.add_function_at_index(len(self.functions)))
552 layout.addWidget(add_button)
554 def update_function_list(self):
555 """Update the function list display."""
556 # Clear existing panes
557 for pane in self.function_panes:
558 pane.setParent(None)
559 self.function_panes.clear()
561 # Create new panes
562 for i, func_item in enumerate(self.functions):
563 pane = FunctionPaneWidget(func_item, i, self.service_adapter, color_scheme=self.color_scheme)
565 # Connect signals
566 pane.parameter_changed.connect(self.on_parameter_changed)
567 pane.add_function.connect(self.add_function_at_index)
568 pane.remove_function.connect(self.remove_function_at_index)
569 pane.move_function.connect(self.move_function)
571 self.function_panes.append(pane)
572 self.container_layout.addWidget(pane)
574 self.container_layout.addStretch()
576 def add_function_at_index(self, index: int):
577 """Add function at specific index."""
578 # Placeholder function
579 new_func_item = (lambda x: x, {})
580 self.functions.insert(index, new_func_item)
581 self.update_function_list()
582 self.functions_changed.emit(self.functions)
584 def remove_function_at_index(self, index: int):
585 """Remove function at specific index."""
586 if 0 <= index < len(self.functions):
587 self.functions.pop(index)
588 self.update_function_list()
589 self.functions_changed.emit(self.functions)
591 def move_function(self, index: int, direction: int):
592 """Move function up or down."""
593 new_index = index + direction
594 if 0 <= new_index < len(self.functions):
595 self.functions[index], self.functions[new_index] = self.functions[new_index], self.functions[index]
596 self.update_function_list()
597 self.functions_changed.emit(self.functions)
599 def on_parameter_changed(self, index: int, param_name: str, value: Any):
600 """Handle parameter changes."""
601 if 0 <= index < len(self.functions):
602 func, kwargs = self.functions[index]
603 new_kwargs = kwargs.copy()
604 new_kwargs[param_name] = value
605 self.functions[index] = (func, new_kwargs)
606 self.functions_changed.emit(self.functions)