Coverage for openhcs/pyqt_gui/widgets/function_pane.py: 0.0%
276 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 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.textual_tui.widgets.shared.parameter_form_manager import ParameterFormManager as TextualParameterFormManager
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 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()}
73 parameter_types = {name: info.param_type for name, info in param_info.items()}
75 self.form_manager = TextualParameterFormManager(parameters, parameter_types, f"func_{index}", param_info)
76 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
77 else:
78 self.form_manager = None
79 self.param_defaults = {}
81 # Internal kwargs tracking (extracted from Textual version)
82 self._internal_kwargs = self.kwargs.copy()
84 # UI components
85 self.parameter_widgets: Dict[str, QWidget] = {}
87 # Setup UI
88 self.setup_ui()
89 self.setup_connections()
91 logger.debug(f"Function pane widget initialized for index {index}")
93 def setup_ui(self):
94 """Setup the user interface."""
95 layout = QVBoxLayout(self)
96 layout.setContentsMargins(5, 5, 5, 5)
97 layout.setSpacing(5)
99 # Function header
100 header_frame = self.create_function_header()
101 layout.addWidget(header_frame)
103 # Control buttons
104 button_frame = self.create_button_panel()
105 layout.addWidget(button_frame)
107 # Parameter form (if function exists and parameters shown)
108 if self.func and self.show_parameters and self.form_manager:
109 parameter_frame = self.create_parameter_form()
110 layout.addWidget(parameter_frame)
112 # Set styling
113 self.setStyleSheet(f"""
114 FunctionPaneWidget {{
115 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
116 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
117 border-radius: 5px;
118 margin: 2px;
119 }}
120 """)
122 def create_function_header(self) -> QWidget:
123 """
124 Create the function header with name and info.
126 Returns:
127 Widget containing function header
128 """
129 frame = QFrame()
130 frame.setFrameStyle(QFrame.Shape.Box)
131 frame.setStyleSheet(f"""
132 QFrame {{
133 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
134 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.separator_color)};
135 border-radius: 3px;
136 padding: 5px;
137 }}
138 """)
140 layout = QHBoxLayout(frame)
142 # Function name with help functionality (reuses Textual TUI help logic)
143 if self.func:
144 func_name = self.func.__name__
145 func_module = self.func.__module__
147 # Function name with help
148 name_label = QLabel(f"🔧 {func_name}")
149 name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold))
150 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
151 layout.addWidget(name_label)
153 # Help indicator for function (import locally to avoid circular imports)
154 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpIndicator
155 help_indicator = HelpIndicator(help_target=self.func, color_scheme=self.color_scheme)
156 layout.addWidget(help_indicator)
158 # Module info
159 if func_module:
160 module_label = QLabel(f"({func_module})")
161 module_label.setFont(QFont("Arial", 8))
162 module_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};")
163 layout.addWidget(module_label)
164 else:
165 name_label = QLabel("No Function Selected")
166 name_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_error)};")
167 layout.addWidget(name_label)
169 layout.addStretch()
171 return frame
173 def create_button_panel(self) -> QWidget:
174 """
175 Create the control button panel.
177 Returns:
178 Widget containing control buttons
179 """
180 frame = QFrame()
181 frame.setFrameStyle(QFrame.Shape.Box)
182 frame.setStyleSheet(f"""
183 QFrame {{
184 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
185 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
186 border-radius: 3px;
187 padding: 3px;
188 }}
189 """)
191 layout = QHBoxLayout(frame)
192 layout.addStretch() # Center the buttons
194 # Button configurations (extracted from Textual version)
195 button_configs = [
196 ("↑", "move_up", "Move function up"),
197 ("↓", "move_down", "Move function down"),
198 ("Add", "add_func", "Add new function"),
199 ("Delete", "remove_func", "Delete this function"),
200 ("Reset", "reset_all", "Reset all parameters"),
201 ]
203 for name, action, tooltip in button_configs:
204 button = QPushButton(name)
205 button.setToolTip(tooltip)
206 button.setMaximumWidth(60)
207 button.setMaximumHeight(25)
208 button.setStyleSheet(f"""
209 QPushButton {{
210 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
211 color: white;
212 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
213 border-radius: 2px;
214 padding: 2px;
215 font-size: 10px;
216 }}
217 QPushButton:hover {{
218 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
219 }}
220 QPushButton:pressed {{
221 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)};
222 }}
223 """)
225 # Connect button to action
226 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a))
228 layout.addWidget(button)
230 layout.addStretch() # Center the buttons
232 return frame
234 def create_parameter_form(self) -> QWidget:
235 """
236 Create the parameter form using extracted business logic.
238 Returns:
239 Widget containing parameter form
240 """
241 group_box = QGroupBox("Parameters")
242 group_box.setStyleSheet(f"""
243 QGroupBox {{
244 font-weight: bold;
245 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
246 border-radius: 3px;
247 margin-top: 10px;
248 padding-top: 10px;
249 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
250 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
251 }}
252 QGroupBox::title {{
253 subcontrol-origin: margin;
254 left: 10px;
255 padding: 0 5px 0 5px;
256 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
257 }}
258 """)
260 layout = QVBoxLayout(group_box)
262 # Use the enhanced ParameterFormManager that has help and reset functionality
263 if self.form_manager:
264 # Import the enhanced PyQt6 ParameterFormManager
265 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager as PyQtParameterFormManager
267 # Create enhanced parameter form manager with help and reset buttons
268 enhanced_form_manager = PyQtParameterFormManager(
269 parameters=self.form_manager.parameters,
270 parameter_types=self.form_manager.parameter_types,
271 field_id=f"func_{self.index}",
272 parameter_info=self.form_manager.parameter_info,
273 use_scroll_area=False, # Don't use scroll area in function panes
274 function_target=self.func, # Pass function for docstring fallback
275 color_scheme=self.color_scheme
276 )
278 # Connect parameter changes
279 enhanced_form_manager.parameter_changed.connect(
280 lambda param_name, value: self.handle_parameter_change(param_name, value)
281 )
283 layout.addWidget(enhanced_form_manager)
285 # Store reference for parameter updates
286 self.enhanced_form_manager = enhanced_form_manager
288 return group_box
290 def create_parameter_widget(self, param_name: str, param_type: type, current_value: Any) -> Optional[QWidget]:
291 """
292 Create parameter widget based on type (simplified TypedWidgetFactory).
294 Args:
295 param_name: Parameter name
296 param_type: Parameter type
297 current_value: Current parameter value
299 Returns:
300 Widget for parameter editing or None
301 """
302 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox
303 from PyQt6.QtGui import QWheelEvent
305 # No-scroll widget classes to prevent accidental value changes
306 class NoScrollSpinBox(QSpinBox):
307 def wheelEvent(self, event: QWheelEvent):
308 event.ignore()
310 class NoScrollDoubleSpinBox(QDoubleSpinBox):
311 def wheelEvent(self, event: QWheelEvent):
312 event.ignore()
314 class NoScrollComboBox(QComboBox):
315 def wheelEvent(self, event: QWheelEvent):
316 event.ignore()
318 # Boolean parameters
319 if param_type == bool:
320 widget = QCheckBox()
321 widget.setChecked(bool(current_value))
322 widget.toggled.connect(lambda checked: self.handle_parameter_change(param_name, checked))
323 return widget
325 # Integer parameters
326 elif param_type == int:
327 widget = NoScrollSpinBox()
328 widget.setRange(-999999, 999999)
329 widget.setValue(int(current_value) if current_value is not None else 0)
330 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
331 return widget
333 # Float parameters
334 elif param_type == float:
335 widget = NoScrollDoubleSpinBox()
336 widget.setRange(-999999.0, 999999.0)
337 widget.setDecimals(6)
338 widget.setValue(float(current_value) if current_value is not None else 0.0)
339 widget.valueChanged.connect(lambda value: self.handle_parameter_change(param_name, value))
340 return widget
342 # Enum parameters
343 elif any(base.__name__ == 'Enum' for base in param_type.__bases__):
344 widget = NoScrollComboBox()
345 for enum_value in param_type:
346 widget.addItem(str(enum_value.value), enum_value)
348 # Set current value
349 if current_value is not None:
350 for i in range(widget.count()):
351 if widget.itemData(i) == current_value:
352 widget.setCurrentIndex(i)
353 break
355 widget.currentIndexChanged.connect(
356 lambda index: self.handle_parameter_change(param_name, widget.itemData(index))
357 )
358 return widget
360 # String and other parameters
361 else:
362 widget = QLineEdit()
363 widget.setText(str(current_value) if current_value is not None else "")
364 widget.textChanged.connect(lambda text: self.handle_parameter_change(param_name, text))
365 return widget
367 def setup_connections(self):
368 """Setup signal/slot connections."""
369 pass # Connections are set up in widget creation
371 def handle_button_action(self, action: str):
372 """
373 Handle button actions (extracted from Textual version).
375 Args:
376 action: Action identifier
377 """
378 if action == "move_up":
379 self.move_function.emit(self.index, -1)
380 elif action == "move_down":
381 self.move_function.emit(self.index, 1)
382 elif action == "add_func":
383 self.add_function.emit(self.index + 1)
384 elif action == "remove_func":
385 self.remove_function.emit(self.index)
386 elif action == "reset_all":
387 self.reset_all_parameters()
389 def handle_parameter_change(self, param_name: str, value: Any):
390 """
391 Handle parameter value changes (extracted from Textual version).
393 Args:
394 param_name: Name of the parameter
395 value: New parameter value
396 """
397 # Update internal kwargs without triggering reactive update
398 self._internal_kwargs[param_name] = value
400 # Update form manager
401 if self.form_manager:
402 self.form_manager.update_parameter(param_name, value)
403 final_value = self.form_manager.parameters[param_name]
404 else:
405 final_value = value
407 # Emit parameter changed signal
408 self.parameter_changed.emit(self.index, param_name, final_value)
410 logger.debug(f"Parameter changed: {param_name} = {final_value}")
412 def reset_all_parameters(self):
413 """Reset all parameters to default values (extracted from Textual version)."""
415 for param_name, default_value in self.param_defaults.items():
416 # Update internal kwargs
417 self._internal_kwargs[param_name] = default_value
419 # Update form manager
420 if self.form_manager:
421 self.form_manager.reset_parameter(param_name, default_value)
423 # Update UI widget
424 if param_name in self.parameter_widgets:
425 widget = self.parameter_widgets[param_name]
426 self.update_widget_value(widget, default_value)
428 # Emit parameter changed signal
429 self.parameter_changed.emit(self.index, param_name, default_value)
431 self.reset_parameters.emit(self.index)
432 logger.debug(f"Reset all parameters for function {self.index}")
434 def update_widget_value(self, widget: QWidget, value: Any):
435 """
436 Update widget value without triggering signals.
438 Args:
439 widget: Widget to update
440 value: New value
441 """
442 from PyQt6.QtWidgets import QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox
443 # Import the no-scroll classes from the same module scope
444 from openhcs.pyqt_gui.shared.typed_widget_factory import NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
446 # Temporarily block signals to avoid recursion
447 widget.blockSignals(True)
449 try:
450 if isinstance(widget, QCheckBox):
451 widget.setChecked(bool(value))
452 elif isinstance(widget, (QSpinBox, NoScrollSpinBox)):
453 widget.setValue(int(value) if value is not None else 0)
454 elif isinstance(widget, (QDoubleSpinBox, NoScrollDoubleSpinBox)):
455 widget.setValue(float(value) if value is not None else 0.0)
456 elif isinstance(widget, (QComboBox, NoScrollComboBox)):
457 for i in range(widget.count()):
458 if widget.itemData(i) == value:
459 widget.setCurrentIndex(i)
460 break
461 elif isinstance(widget, QLineEdit):
462 widget.setText(str(value) if value is not None else "")
463 finally:
464 widget.blockSignals(False)
466 def get_current_kwargs(self) -> Dict[str, Any]:
467 """
468 Get current kwargs values (extracted from Textual version).
470 Returns:
471 Current parameter values
472 """
473 return self._internal_kwargs.copy()
475 def sync_kwargs(self):
476 """Sync internal kwargs to main kwargs (extracted from Textual version)."""
477 self.kwargs = self._internal_kwargs.copy()
479 def update_function(self, func_item: Tuple[Callable, Dict]):
480 """
481 Update the function and parameters.
483 Args:
484 func_item: New function item tuple
485 """
486 self.func, self.kwargs = func_item
487 self._internal_kwargs = self.kwargs.copy()
489 # Recreate form manager
490 if self.func:
491 param_info = SignatureAnalyzer.analyze(self.func)
492 parameters = {name: self.kwargs.get(name, info.default_value) for name, info in param_info.items()}
493 parameter_types = {name: info.param_type for name, info in param_info.items()}
495 self.form_manager = TextualParameterFormManager(parameters, parameter_types, f"func_{self.index}", param_info)
496 self.param_defaults = {name: info.default_value for name, info in param_info.items()}
497 else:
498 self.form_manager = None
499 self.param_defaults = {}
501 # Rebuild UI
502 self.setup_ui()
504 logger.debug(f"Updated function for index {self.index}")
507class FunctionListWidget(QWidget):
508 """
509 PyQt6 Function List Widget.
511 Container for multiple FunctionPaneWidgets with list management.
512 """
514 # Signals
515 functions_changed = pyqtSignal(list) # List of function items
517 def __init__(self, service_adapter, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
518 """
519 Initialize the function list widget.
521 Args:
522 service_adapter: PyQt service adapter
523 parent: Parent widget
524 """
525 super().__init__(parent)
527 # Initialize color scheme
528 self.color_scheme = color_scheme or PyQt6ColorScheme()
530 self.service_adapter = service_adapter
531 self.functions: List[Tuple[Callable, Dict]] = []
532 self.function_panes: List[FunctionPaneWidget] = []
534 # Setup UI
535 self.setup_ui()
537 def setup_ui(self):
538 """Setup the user interface."""
539 layout = QVBoxLayout(self)
541 # Scroll area for function panes
542 scroll_area = QScrollArea()
543 scroll_area.setWidgetResizable(True)
544 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
545 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
547 # Container widget for function panes
548 self.container_widget = QWidget()
549 self.container_layout = QVBoxLayout(self.container_widget)
550 self.container_layout.setSpacing(5)
552 scroll_area.setWidget(self.container_widget)
553 layout.addWidget(scroll_area)
555 # Add function button
556 add_button = QPushButton("Add Function")
557 add_button.clicked.connect(lambda: self.add_function_at_index(len(self.functions)))
558 layout.addWidget(add_button)
560 def update_function_list(self):
561 """Update the function list display."""
562 # Clear existing panes
563 for pane in self.function_panes:
564 pane.setParent(None)
565 self.function_panes.clear()
567 # Create new panes
568 for i, func_item in enumerate(self.functions):
569 pane = FunctionPaneWidget(func_item, i, self.service_adapter, color_scheme=self.color_scheme)
571 # Connect signals
572 pane.parameter_changed.connect(self.on_parameter_changed)
573 pane.add_function.connect(self.add_function_at_index)
574 pane.remove_function.connect(self.remove_function_at_index)
575 pane.move_function.connect(self.move_function)
577 self.function_panes.append(pane)
578 self.container_layout.addWidget(pane)
580 self.container_layout.addStretch()
582 def add_function_at_index(self, index: int):
583 """Add function at specific index."""
584 # Placeholder function
585 new_func_item = (lambda x: x, {})
586 self.functions.insert(index, new_func_item)
587 self.update_function_list()
588 self.functions_changed.emit(self.functions)
590 def remove_function_at_index(self, index: int):
591 """Remove function at specific index."""
592 if 0 <= index < len(self.functions):
593 self.functions.pop(index)
594 self.update_function_list()
595 self.functions_changed.emit(self.functions)
597 def move_function(self, index: int, direction: int):
598 """Move function up or down."""
599 new_index = index + direction
600 if 0 <= new_index < len(self.functions):
601 self.functions[index], self.functions[new_index] = self.functions[new_index], self.functions[index]
602 self.update_function_list()
603 self.functions_changed.emit(self.functions)
605 def on_parameter_changed(self, index: int, param_name: str, value: Any):
606 """Handle parameter changes."""
607 if 0 <= index < len(self.functions):
608 func, kwargs = self.functions[index]
609 new_kwargs = kwargs.copy()
610 new_kwargs[param_name] = value
611 self.functions[index] = (func, new_kwargs)
612 self.functions_changed.emit(self.functions)