Coverage for openhcs/pyqt_gui/widgets/function_pane.py: 0.0%
242 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +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
11from PyQt6.QtWidgets import (
12 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
13 QFrame, QScrollArea, QGroupBox
14)
15from PyQt6.QtCore import Qt, pyqtSignal
16from PyQt6.QtGui import QFont
18from openhcs.introspection.signature_analyzer import SignatureAnalyzer
20# Import PyQt6 help components (using same pattern as Textual TUI)
21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
23logger = logging.getLogger(__name__)
26class FunctionPaneWidget(QWidget):
27 """
28 PyQt6 Function Pane Widget.
30 Displays individual function with editable parameters and control buttons.
31 Preserves all business logic from Textual version with clean PyQt6 UI.
32 """
34 # Signals
35 parameter_changed = pyqtSignal(int, str, object) # index, param_name, value
36 function_changed = pyqtSignal(int) # index
37 add_function = pyqtSignal(int) # index
38 remove_function = pyqtSignal(int) # index
39 move_function = pyqtSignal(int, int) # index, direction
40 reset_parameters = pyqtSignal(int) # index
42 def __init__(self, func_item: Tuple[Callable, Dict], index: int, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
43 """
44 Initialize the function pane widget.
46 Args:
47 func_item: Tuple of (function, kwargs)
48 index: Function index in the list
49 service_adapter: PyQt service adapter for dialogs and operations
50 parent: Parent widget
51 """
52 super().__init__(parent)
54 # Initialize color scheme
55 self.color_scheme = color_scheme or PyQt6ColorScheme()
57 # Core dependencies
58 self.service_adapter = service_adapter
60 # Business logic state (extracted from Textual version)
61 self.func, self.kwargs = func_item
62 self.index = index
63 self.show_parameters = True
65 # Parameter management (extracted from Textual version)
66 if self.func:
67 param_info = SignatureAnalyzer.analyze(self.func)
69 # Store function signature defaults
70 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
71 else:
72 self.param_defaults = {}
74 # Form manager will be created in create_parameter_form() when UI is built
75 self.form_manager = None
77 # Internal kwargs tracking (extracted from Textual version)
78 self._internal_kwargs = self.kwargs.copy()
80 # UI components
81 self.parameter_widgets: Dict[str, QWidget] = {}
83 # Setup UI
84 self.setup_ui()
85 self.setup_connections()
87 logger.debug(f"Function pane widget initialized for index {index}")
89 def setup_ui(self):
90 """Setup the user interface."""
91 layout = QVBoxLayout(self)
92 layout.setContentsMargins(5, 5, 5, 5)
93 layout.setSpacing(5)
95 # Combined header with title and buttons on same row
96 header_frame = self.create_combined_header()
97 layout.addWidget(header_frame)
99 # Parameter form (if function exists and parameters shown)
100 if self.func and self.show_parameters:
101 parameter_frame = self.create_parameter_form()
102 layout.addWidget(parameter_frame)
104 # Set styling
105 self.setStyleSheet(f"""
106 FunctionPaneWidget {{
107 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
108 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
109 border-radius: 5px;
110 margin: 2px;
111 }}
112 """)
114 def create_combined_header(self) -> QWidget:
115 """
116 Create combined header with title and buttons on the same row.
118 Returns:
119 Widget containing title and control buttons
120 """
121 frame = QFrame()
122 frame.setFrameStyle(QFrame.Shape.Box)
123 frame.setStyleSheet(f"""
124 QFrame {{
125 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
126 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.separator_color)};
127 border-radius: 3px;
128 padding: 5px;
129 }}
130 """)
132 layout = QHBoxLayout(frame)
133 layout.setSpacing(10)
135 # Function name with help functionality (left side)
136 if self.func:
137 func_name = self.func.__name__
138 func_module = self.func.__module__
140 # Function name with help
141 name_label = QLabel(f"🔧 {func_name}")
142 name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold))
143 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
144 layout.addWidget(name_label)
146 # Help indicator for function (import locally to avoid circular imports)
147 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator
148 help_indicator = HelpIndicator(help_target=self.func, color_scheme=self.color_scheme)
149 layout.addWidget(help_indicator)
151 # Module info
152 if func_module:
153 module_label = QLabel(f"({func_module})")
154 module_label.setFont(QFont("Arial", 8))
155 module_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};")
156 layout.addWidget(module_label)
157 else:
158 name_label = QLabel("No Function Selected")
159 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_error)};")
160 layout.addWidget(name_label)
162 layout.addStretch()
164 # Control buttons (right side) - using parameter form manager style
165 from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
166 style_gen = StyleSheetGenerator(self.color_scheme)
167 button_styles = style_gen.generate_config_button_styles()
169 # Button configurations
170 button_configs = [
171 ("↑", "move_up", "Move function up"),
172 ("↓", "move_down", "Move function down"),
173 ("Add", "add_func", "Add new function"),
174 ("Delete", "remove_func", "Delete this function"),
175 ("Reset", "reset_all", "Reset all parameters"),
176 ]
178 for name, action, tooltip in button_configs:
179 button = QPushButton(name)
180 button.setToolTip(tooltip)
181 button.setMaximumWidth(60)
183 # Use reset button style for all buttons (consistent with parameter form manager)
184 button.setStyleSheet(button_styles["reset"])
186 # Connect button to action
187 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a))
189 layout.addWidget(button)
191 return frame
193 def create_parameter_form(self) -> QWidget:
194 """
195 Create the parameter form using extracted business logic.
197 Returns:
198 Widget containing parameter form
199 """
200 group_box = QGroupBox("Parameters")
201 group_box.setStyleSheet(f"""
202 QGroupBox {{
203 font-weight: bold;
204 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
205 border-radius: 3px;
206 margin-top: 10px;
207 padding-top: 10px;
208 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
209 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
210 }}
211 QGroupBox::title {{
212 subcontrol-origin: margin;
213 left: 10px;
214 padding: 0 5px 0 5px;
215 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
216 }}
217 """)
219 layout = QVBoxLayout(group_box)
221 # Create the ParameterFormManager with help and reset functionality
222 # Import the enhanced PyQt6 ParameterFormManager
223 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager as PyQtParameterFormManager
225 # Create form manager with initial_values to load saved kwargs
226 self.form_manager = PyQtParameterFormManager(
227 object_instance=self.func, # Pass function as the object to build form for
228 field_id=f"func_{self.index}", # Use function index as field identifier
229 parent=self, # Pass self as parent widget
230 context_obj=None, # Functions don't need context for placeholder resolution
231 initial_values=self.kwargs, # Pass saved kwargs to populate form fields
232 color_scheme=self.color_scheme # Pass color_scheme for consistent theming
233 )
235 # Connect parameter changes
236 self.form_manager.parameter_changed.connect(
237 lambda param_name, value: self.handle_parameter_change(param_name, value)
238 )
240 layout.addWidget(self.form_manager)
242 return group_box
244 def create_parameter_widget(self, param_name: str, param_type: type, current_value: Any) -> Optional[QWidget]:
245 """
246 Create parameter widget based on type.
248 Args:
249 param_name: Parameter name
250 param_type: Parameter type
251 current_value: Current parameter value
253 Returns:
254 Widget for parameter editing or None
255 """
256 from PyQt6.QtWidgets import QLineEdit
257 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
258 NoScrollSpinBox, NoScrollDoubleSpinBox
259 )
261 # Boolean parameters
262 if param_type == bool:
263 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
264 widget = NoneAwareCheckBox()
265 widget.set_value(current_value) # Use set_value to handle None properly
266 widget.toggled.connect(lambda checked: self.handle_parameter_change(param_name, checked))
267 return widget
269 # Integer parameters
270 elif param_type == int:
271 widget = NoScrollSpinBox()
272 widget.setRange(-999999, 999999)
273 widget.setValue(int(current_value) if current_value is not None else 0)
274 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
275 return widget
277 # Float parameters
278 elif param_type == float:
279 widget = NoScrollDoubleSpinBox()
280 widget.setRange(-999999.0, 999999.0)
281 widget.setDecimals(6)
282 widget.setValue(float(current_value) if current_value is not None else 0.0)
283 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
284 return widget
286 # Enum parameters
287 elif any(base.__name__ == 'Enum' for base in param_type.__bases__):
288 from openhcs.pyqt_gui.widgets.shared.widget_strategies import create_enum_widget_unified
290 # Use the single source of truth for enum widget creation
291 widget = create_enum_widget_unified(param_type, current_value)
293 widget.currentIndexChanged.connect(
294 lambda index: self.handle_parameter_change(param_name, widget.itemData(index))
295 )
296 return widget
298 # String and other parameters
299 else:
300 widget = QLineEdit()
301 widget.setText(str(current_value) if current_value is not None else "")
302 widget.textChanged.connect(lambda text: self.handle_parameter_change(param_name, text))
303 return widget
305 def setup_connections(self):
306 """Setup signal/slot connections."""
307 pass # Connections are set up in widget creation
309 def handle_button_action(self, action: str):
310 """
311 Handle button actions (extracted from Textual version).
313 Args:
314 action: Action identifier
315 """
316 if action == "move_up":
317 self.move_function.emit(self.index, -1)
318 elif action == "move_down":
319 self.move_function.emit(self.index, 1)
320 elif action == "add_func":
321 self.add_function.emit(self.index + 1)
322 elif action == "remove_func":
323 self.remove_function.emit(self.index)
324 elif action == "reset_all":
325 self.reset_all_parameters()
327 def handle_parameter_change(self, param_name: str, value: Any):
328 """
329 Handle parameter value changes (extracted from Textual version).
331 Args:
332 param_name: Name of the parameter
333 value: New parameter value
334 """
335 # Update internal kwargs without triggering reactive update
336 self._internal_kwargs[param_name] = value
338 # The form manager already has the updated value (it emitted this signal)
339 # No need to call update_parameter() again - that would be redundant
341 # Emit parameter changed signal to notify parent (function list editor)
342 self.parameter_changed.emit(self.index, param_name, value)
344 logger.debug(f"Parameter changed: {param_name} = {value}")
346 def reset_all_parameters(self):
347 """Reset all parameters to default values using PyQt6 form manager."""
348 if not self.form_manager:
349 return
351 # Reset all parameters - form manager will use signature defaults from param_defaults
352 for param_name in list(self.form_manager.parameters.keys()):
353 self.form_manager.reset_parameter(param_name)
355 # Update internal kwargs to match the reset values
356 self._internal_kwargs = self.form_manager.get_current_values()
358 # Emit parameter changed signals for each reset parameter
359 for param_name, default_value in self.param_defaults.items():
360 self.parameter_changed.emit(self.index, param_name, default_value)
362 self.reset_parameters.emit(self.index)
364 def update_widget_value(self, widget: QWidget, value: Any):
365 """
366 Update widget value without triggering signals.
368 Args:
369 widget: Widget to update
370 value: New value
371 """
372 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox
373 # Import the no-scroll classes from single source of truth
374 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
375 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
376 )
378 # Temporarily block signals to avoid recursion
379 widget.blockSignals(True)
381 try:
382 if isinstance(widget, QCheckBox):
383 widget.setChecked(bool(value))
384 elif isinstance(widget, (QSpinBox, NoScrollSpinBox)):
385 widget.setValue(int(value) if value is not None else 0)
386 elif isinstance(widget, (QDoubleSpinBox, NoScrollDoubleSpinBox)):
387 widget.setValue(float(value) if value is not None else 0.0)
388 elif isinstance(widget, (QComboBox, NoScrollComboBox)):
389 for i in range(widget.count()):
390 if widget.itemData(i) == value:
391 widget.setCurrentIndex(i)
392 break
393 elif isinstance(widget, QLineEdit):
394 widget.setText(str(value) if value is not None else "")
395 finally:
396 widget.blockSignals(False)
398 def get_current_kwargs(self) -> Dict[str, Any]:
399 """
400 Get current kwargs values (extracted from Textual version).
402 Returns:
403 Current parameter values
404 """
405 return self._internal_kwargs.copy()
407 def sync_kwargs(self):
408 """Sync internal kwargs to main kwargs (extracted from Textual version)."""
409 self.kwargs = self._internal_kwargs.copy()
411 def update_function(self, func_item: Tuple[Callable, Dict]):
412 """
413 Update the function and parameters.
415 Args:
416 func_item: New function item tuple
417 """
418 self.func, self.kwargs = func_item
419 self._internal_kwargs = self.kwargs.copy()
421 # Update parameter defaults
422 if self.func:
423 param_info = SignatureAnalyzer.analyze(self.func)
424 # Store function signature defaults
425 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
426 else:
427 self.param_defaults = {}
429 # Form manager will be recreated in create_parameter_form() when UI is rebuilt
430 self.form_manager = None
432 # Rebuild UI (this will create the form manager in create_parameter_form())
433 self.setup_ui()
435 logger.debug(f"Updated function for index {self.index}")
438class FunctionListWidget(QWidget):
439 """
440 PyQt6 Function List Widget.
442 Container for multiple FunctionPaneWidgets with list management.
443 """
445 # Signals
446 functions_changed = pyqtSignal(list) # List of function items
448 def __init__(self, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
449 """
450 Initialize the function list widget.
452 Args:
453 service_adapter: PyQt service adapter
454 parent: Parent widget
455 """
456 super().__init__(parent)
458 # Initialize color scheme
459 self.color_scheme = color_scheme or PyQt6ColorScheme()
461 self.service_adapter = service_adapter
462 self.functions: List[Tuple[Callable, Dict]] = []
463 self.function_panes: List[FunctionPaneWidget] = []
465 # Setup UI
466 self.setup_ui()
468 def setup_ui(self):
469 """Setup the user interface."""
470 layout = QVBoxLayout(self)
472 # Scroll area for function panes
473 scroll_area = QScrollArea()
474 scroll_area.setWidgetResizable(True)
475 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
476 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
478 # Container widget for function panes
479 self.container_widget = QWidget()
480 self.container_layout = QVBoxLayout(self.container_widget)
481 self.container_layout.setSpacing(5)
483 scroll_area.setWidget(self.container_widget)
484 layout.addWidget(scroll_area)
486 # Add function button
487 add_button = QPushButton("Add Function")
488 add_button.clicked.connect(lambda: self.add_function_at_index(len(self.functions)))
489 layout.addWidget(add_button)
491 def update_function_list(self):
492 """Update the function list display."""
493 # Clear existing panes - CRITICAL: Manually unregister form managers BEFORE deleteLater()
494 # This prevents RuntimeError when new widgets try to connect to deleted managers
495 for pane in self.function_panes:
496 # Explicitly unregister the form manager before scheduling deletion
497 if hasattr(pane, 'form_manager') and pane.form_manager is not None:
498 try:
499 pane.form_manager.unregister_from_cross_window_updates()
500 except RuntimeError:
501 pass # Already deleted
502 pane.deleteLater() # Schedule for deletion - triggers destroyed signal
503 self.function_panes.clear()
505 # Create new panes
506 for i, func_item in enumerate(self.functions):
507 pane = FunctionPaneWidget(func_item, i, self.service_adapter, color_scheme=self.color_scheme)
509 # Connect signals
510 pane.parameter_changed.connect(self.on_parameter_changed)
511 pane.add_function.connect(self.add_function_at_index)
512 pane.remove_function.connect(self.remove_function_at_index)
513 pane.move_function.connect(self.move_function)
515 self.function_panes.append(pane)
516 self.container_layout.addWidget(pane)
518 self.container_layout.addStretch()
520 def add_function_at_index(self, index: int):
521 """Add function at specific index."""
522 # Placeholder function
523 new_func_item = (lambda x: x, {})
524 self.functions.insert(index, new_func_item)
525 self.update_function_list()
526 self.functions_changed.emit(self.functions)
528 def remove_function_at_index(self, index: int):
529 """Remove function at specific index."""
530 if 0 <= index < len(self.functions):
531 self.functions.pop(index)
532 self.update_function_list()
533 self.functions_changed.emit(self.functions)
535 def move_function(self, index: int, direction: int):
536 """Move function up or down."""
537 new_index = index + direction
538 if 0 <= new_index < len(self.functions):
539 self.functions[index], self.functions[new_index] = self.functions[new_index], self.functions[index]
540 self.update_function_list()
541 self.functions_changed.emit(self.functions)
543 def on_parameter_changed(self, index: int, param_name: str, value: Any):
544 """Handle parameter changes."""
545 if 0 <= index < len(self.functions):
546 func, kwargs = self.functions[index]
547 new_kwargs = kwargs.copy()
548 new_kwargs[param_name] = value
549 self.functions[index] = (func, new_kwargs)
550 self.functions_changed.emit(self.functions)